├── Exception ├── ExceptionInterface.php ├── LogicException.php ├── RuntimeException.php ├── InvalidArgumentException.php ├── InvalidDefinitionException.php ├── UndefinedTransitionException.php ├── NotEnabledTransitionException.php └── TransitionException.php ├── SupportStrategy ├── WorkflowSupportStrategyInterface.php └── InstanceOfSupportStrategy.php ├── README.md ├── Event ├── HasContextTrait.php ├── EnterEvent.php ├── LeaveEvent.php ├── EnteredEvent.php ├── AnnounceEvent.php ├── CompletedEvent.php ├── TransitionEvent.php ├── EventNameTrait.php ├── Event.php └── GuardEvent.php ├── EventListener ├── GuardExpression.php ├── ExpressionLanguage.php ├── AuditTrailListener.php └── GuardListener.php ├── Validator ├── DefinitionValidatorInterface.php ├── WorkflowValidator.php └── StateMachineValidator.php ├── Dumper ├── DumperInterface.php ├── StateMachineGraphvizDumper.php ├── MermaidDumper.php ├── PlantUmlDumper.php └── GraphvizDumper.php ├── Metadata ├── GetMetadataTrait.php ├── MetadataStoreInterface.php └── InMemoryMetadataStore.php ├── StateMachine.php ├── MarkingStore ├── MarkingStoreInterface.php └── MethodMarkingStore.php ├── LICENSE ├── Attribute ├── BuildEventNameTrait.php ├── AsEnterListener.php ├── AsLeaveListener.php ├── AsEnteredListener.php ├── AsGuardListener.php ├── AsAnnounceListener.php ├── AsCompletedListener.php └── AsTransitionListener.php ├── Transition.php ├── DependencyInjection ├── WorkflowDebugPass.php ├── WorkflowValidatorPass.php └── WorkflowGuardListenerPass.php ├── composer.json ├── TransitionBlockerList.php ├── WorkflowInterface.php ├── WorkflowEvents.php ├── Registry.php ├── Marking.php ├── TransitionBlocker.php ├── DefinitionBuilder.php ├── Definition.php ├── Debug └── TraceableWorkflow.php ├── CHANGELOG.md ├── DataCollector └── WorkflowDataCollector.php └── Workflow.php /Exception/ExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Exception; 13 | 14 | /** 15 | * @author Fabien Potencier 16 | * @author Grégoire Pineau 17 | */ 18 | interface ExceptionInterface extends \Throwable 19 | { 20 | } 21 | -------------------------------------------------------------------------------- /Exception/LogicException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Exception; 13 | 14 | /** 15 | * @author Fabien Potencier 16 | * @author Grégoire Pineau 17 | */ 18 | class LogicException extends \LogicException implements ExceptionInterface 19 | { 20 | } 21 | -------------------------------------------------------------------------------- /Exception/RuntimeException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Exception; 13 | 14 | /** 15 | * Base RuntimeException for the Workflow component. 16 | * 17 | * @author Alain Flaus 18 | */ 19 | class RuntimeException extends \RuntimeException implements ExceptionInterface 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /Exception/InvalidArgumentException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Exception; 13 | 14 | /** 15 | * @author Fabien Potencier 16 | * @author Grégoire Pineau 17 | */ 18 | class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface 19 | { 20 | } 21 | -------------------------------------------------------------------------------- /Exception/InvalidDefinitionException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Exception; 13 | 14 | /** 15 | * Thrown by the DefinitionValidatorInterface when the definition is invalid. 16 | * 17 | * @author Tobias Nyholm 18 | */ 19 | class InvalidDefinitionException extends LogicException 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /SupportStrategy/WorkflowSupportStrategyInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\SupportStrategy; 13 | 14 | use Symfony\Component\Workflow\WorkflowInterface; 15 | 16 | /** 17 | * @author Amrouche Hamza 18 | */ 19 | interface WorkflowSupportStrategyInterface 20 | { 21 | public function supports(WorkflowInterface $workflow, object $subject): bool; 22 | } 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Workflow Component 2 | =================== 3 | 4 | The Workflow component provides tools for managing a workflow or finite state 5 | machine. 6 | 7 | Sponsor 8 | ------- 9 | 10 | Help Symfony by [sponsoring][1] its development! 11 | 12 | Resources 13 | --------- 14 | 15 | * [Documentation](https://symfony.com/doc/current/components/workflow.html) 16 | * [Contributing](https://symfony.com/doc/current/contributing/index.html) 17 | * [Report issues](https://github.com/symfony/symfony/issues) and 18 | [send Pull Requests](https://github.com/symfony/symfony/pulls) 19 | in the [main Symfony repository](https://github.com/symfony/symfony) 20 | 21 | [1]: https://symfony.com/sponsor 22 | -------------------------------------------------------------------------------- /Event/HasContextTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Event; 13 | 14 | /** 15 | * @author Fabien Potencier 16 | * @author Grégoire Pineau 17 | * @author Hugo Hamon 18 | * 19 | * @internal 20 | */ 21 | trait HasContextTrait 22 | { 23 | private array $context = []; 24 | 25 | public function getContext(): array 26 | { 27 | return $this->context; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /EventListener/GuardExpression.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\EventListener; 13 | 14 | use Symfony\Component\Workflow\Transition; 15 | 16 | class GuardExpression 17 | { 18 | public function __construct( 19 | private Transition $transition, 20 | private string $expression, 21 | ) { 22 | } 23 | 24 | public function getTransition(): Transition 25 | { 26 | return $this->transition; 27 | } 28 | 29 | public function getExpression(): string 30 | { 31 | return $this->expression; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Validator/DefinitionValidatorInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Validator; 13 | 14 | use Symfony\Component\Workflow\Definition; 15 | use Symfony\Component\Workflow\Exception\InvalidDefinitionException; 16 | 17 | /** 18 | * @author Tobias Nyholm 19 | * @author Grégoire Pineau 20 | */ 21 | interface DefinitionValidatorInterface 22 | { 23 | /** 24 | * @throws InvalidDefinitionException on invalid definition 25 | */ 26 | public function validate(Definition $definition, string $name): void; 27 | } 28 | -------------------------------------------------------------------------------- /Dumper/DumperInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Dumper; 13 | 14 | use Symfony\Component\Workflow\Definition; 15 | use Symfony\Component\Workflow\Marking; 16 | 17 | /** 18 | * DumperInterface is the interface implemented by workflow dumper classes. 19 | * 20 | * @author Fabien Potencier 21 | * @author Grégoire Pineau 22 | */ 23 | interface DumperInterface 24 | { 25 | /** 26 | * Dumps a workflow definition. 27 | */ 28 | public function dump(Definition $definition, ?Marking $marking = null, array $options = []): string; 29 | } 30 | -------------------------------------------------------------------------------- /Metadata/GetMetadataTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Metadata; 13 | 14 | use Symfony\Component\Workflow\Transition; 15 | 16 | /** 17 | * @author Grégoire Pineau 18 | */ 19 | trait GetMetadataTrait 20 | { 21 | public function getMetadata(string $key, string|Transition|null $subject = null): mixed 22 | { 23 | if (null === $subject) { 24 | return $this->getWorkflowMetadata()[$key] ?? null; 25 | } 26 | 27 | $metadataBag = \is_string($subject) ? $this->getPlaceMetadata($subject) : $this->getTransitionMetadata($subject); 28 | 29 | return $metadataBag[$key] ?? null; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Event/EnterEvent.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Event; 13 | 14 | use Symfony\Component\Workflow\Marking; 15 | use Symfony\Component\Workflow\Transition; 16 | use Symfony\Component\Workflow\WorkflowInterface; 17 | 18 | final class EnterEvent extends Event 19 | { 20 | use EventNameTrait { 21 | getNameForPlace as public getName; 22 | } 23 | use HasContextTrait; 24 | 25 | public function __construct(object $subject, Marking $marking, ?Transition $transition = null, ?WorkflowInterface $workflow = null, array $context = []) 26 | { 27 | parent::__construct($subject, $marking, $transition, $workflow); 28 | 29 | $this->context = $context; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Event/LeaveEvent.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Event; 13 | 14 | use Symfony\Component\Workflow\Marking; 15 | use Symfony\Component\Workflow\Transition; 16 | use Symfony\Component\Workflow\WorkflowInterface; 17 | 18 | final class LeaveEvent extends Event 19 | { 20 | use EventNameTrait { 21 | getNameForPlace as public getName; 22 | } 23 | use HasContextTrait; 24 | 25 | public function __construct(object $subject, Marking $marking, ?Transition $transition = null, ?WorkflowInterface $workflow = null, array $context = []) 26 | { 27 | parent::__construct($subject, $marking, $transition, $workflow); 28 | 29 | $this->context = $context; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Event/EnteredEvent.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Event; 13 | 14 | use Symfony\Component\Workflow\Marking; 15 | use Symfony\Component\Workflow\Transition; 16 | use Symfony\Component\Workflow\WorkflowInterface; 17 | 18 | final class EnteredEvent extends Event 19 | { 20 | use EventNameTrait { 21 | getNameForPlace as public getName; 22 | } 23 | use HasContextTrait; 24 | 25 | public function __construct(object $subject, Marking $marking, ?Transition $transition = null, ?WorkflowInterface $workflow = null, array $context = []) 26 | { 27 | parent::__construct($subject, $marking, $transition, $workflow); 28 | 29 | $this->context = $context; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Event/AnnounceEvent.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Event; 13 | 14 | use Symfony\Component\Workflow\Marking; 15 | use Symfony\Component\Workflow\Transition; 16 | use Symfony\Component\Workflow\WorkflowInterface; 17 | 18 | final class AnnounceEvent extends Event 19 | { 20 | use EventNameTrait { 21 | getNameForTransition as public getName; 22 | } 23 | use HasContextTrait; 24 | 25 | public function __construct(object $subject, Marking $marking, ?Transition $transition = null, ?WorkflowInterface $workflow = null, array $context = []) 26 | { 27 | parent::__construct($subject, $marking, $transition, $workflow); 28 | 29 | $this->context = $context; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Event/CompletedEvent.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Event; 13 | 14 | use Symfony\Component\Workflow\Marking; 15 | use Symfony\Component\Workflow\Transition; 16 | use Symfony\Component\Workflow\WorkflowInterface; 17 | 18 | final class CompletedEvent extends Event 19 | { 20 | use EventNameTrait { 21 | getNameForTransition as public getName; 22 | } 23 | use HasContextTrait; 24 | 25 | public function __construct(object $subject, Marking $marking, ?Transition $transition = null, ?WorkflowInterface $workflow = null, array $context = []) 26 | { 27 | parent::__construct($subject, $marking, $transition, $workflow); 28 | 29 | $this->context = $context; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Exception/UndefinedTransitionException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Exception; 13 | 14 | use Symfony\Component\Workflow\WorkflowInterface; 15 | 16 | /** 17 | * Thrown by Workflow when an undefined transition is applied on a subject. 18 | * 19 | * @author Grégoire Pineau 20 | */ 21 | class UndefinedTransitionException extends TransitionException 22 | { 23 | public function __construct(object $subject, string $transitionName, WorkflowInterface $workflow, array $context = []) 24 | { 25 | parent::__construct($subject, $transitionName, $workflow, \sprintf('Transition "%s" is not defined for workflow "%s".', $transitionName, $workflow->getName()), $context); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /StateMachine.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow; 13 | 14 | use Symfony\Component\Workflow\MarkingStore\MarkingStoreInterface; 15 | use Symfony\Component\Workflow\MarkingStore\MethodMarkingStore; 16 | use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; 17 | 18 | /** 19 | * @author Tobias Nyholm 20 | */ 21 | class StateMachine extends Workflow 22 | { 23 | public function __construct(Definition $definition, ?MarkingStoreInterface $markingStore = null, ?EventDispatcherInterface $dispatcher = null, string $name = 'unnamed', ?array $eventsToDispatch = null) 24 | { 25 | parent::__construct($definition, $markingStore ?? new MethodMarkingStore(true), $dispatcher, $name, $eventsToDispatch); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /SupportStrategy/InstanceOfSupportStrategy.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\SupportStrategy; 13 | 14 | use Symfony\Component\Workflow\WorkflowInterface; 15 | 16 | /** 17 | * @author Andreas Kleemann 18 | * @author Amrouche Hamza 19 | */ 20 | final class InstanceOfSupportStrategy implements WorkflowSupportStrategyInterface 21 | { 22 | public function __construct( 23 | private string $className, 24 | ) { 25 | } 26 | 27 | public function supports(WorkflowInterface $workflow, object $subject): bool 28 | { 29 | return $subject instanceof $this->className; 30 | } 31 | 32 | public function getClassName(): string 33 | { 34 | return $this->className; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /MarkingStore/MarkingStoreInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\MarkingStore; 13 | 14 | use Symfony\Component\Workflow\Marking; 15 | 16 | /** 17 | * MarkingStoreInterface is the interface between the Workflow Component and a 18 | * plain old PHP object: the subject. 19 | * 20 | * It converts the Marking into something understandable by the subject and vice 21 | * versa. 22 | * 23 | * @author Grégoire Pineau 24 | */ 25 | interface MarkingStoreInterface 26 | { 27 | /** 28 | * Gets a Marking from a subject. 29 | */ 30 | public function getMarking(object $subject): Marking; 31 | 32 | /** 33 | * Sets a Marking to a subject. 34 | */ 35 | public function setMarking(object $subject, Marking $marking, array $context = []): void; 36 | } 37 | -------------------------------------------------------------------------------- /Event/TransitionEvent.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Event; 13 | 14 | use Symfony\Component\Workflow\Marking; 15 | use Symfony\Component\Workflow\Transition; 16 | use Symfony\Component\Workflow\WorkflowInterface; 17 | 18 | final class TransitionEvent extends Event 19 | { 20 | use EventNameTrait { 21 | getNameForTransition as public getName; 22 | } 23 | use HasContextTrait; 24 | 25 | public function __construct(object $subject, Marking $marking, ?Transition $transition = null, ?WorkflowInterface $workflow = null, array $context = []) 26 | { 27 | parent::__construct($subject, $marking, $transition, $workflow); 28 | 29 | $this->context = $context; 30 | } 31 | 32 | public function setContext(array $context): void 33 | { 34 | $this->context = $context; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-present Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Attribute/BuildEventNameTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Attribute; 13 | 14 | use Symfony\Component\Workflow\Exception\LogicException; 15 | 16 | /** 17 | * @author Grégoire Pineau 18 | * 19 | * @internal 20 | */ 21 | trait BuildEventNameTrait 22 | { 23 | private static function buildEventName(string $keyword, string $argument, ?string $workflow = null, ?string $node = null): string 24 | { 25 | if (null === $workflow) { 26 | if (null !== $node) { 27 | throw new LogicException(\sprintf('The "%s" argument of "%s" cannot be used without a "workflow" argument.', $argument, self::class)); 28 | } 29 | 30 | return \sprintf('workflow.%s', $keyword); 31 | } 32 | 33 | if (null === $node) { 34 | return \sprintf('workflow.%s.%s', $workflow, $keyword); 35 | } 36 | 37 | return \sprintf('workflow.%s.%s.%s', $workflow, $keyword, $node); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Exception/NotEnabledTransitionException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Exception; 13 | 14 | use Symfony\Component\Workflow\TransitionBlockerList; 15 | use Symfony\Component\Workflow\WorkflowInterface; 16 | 17 | /** 18 | * Thrown when a transition cannot be applied on a subject. 19 | * 20 | * @author Grégoire Pineau 21 | */ 22 | class NotEnabledTransitionException extends TransitionException 23 | { 24 | public function __construct( 25 | object $subject, 26 | string $transitionName, 27 | WorkflowInterface $workflow, 28 | private TransitionBlockerList $transitionBlockerList, 29 | array $context = [], 30 | ) { 31 | parent::__construct($subject, $transitionName, $workflow, \sprintf('Cannot apply transition "%s" on workflow "%s".', $transitionName, $workflow->getName()), $context); 32 | } 33 | 34 | public function getTransitionBlockerList(): TransitionBlockerList 35 | { 36 | return $this->transitionBlockerList; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Transition.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow; 13 | 14 | /** 15 | * @author Fabien Potencier 16 | * @author Grégoire Pineau 17 | */ 18 | class Transition 19 | { 20 | private array $froms; 21 | private array $tos; 22 | 23 | /** 24 | * @param string|string[] $froms 25 | * @param string|string[] $tos 26 | */ 27 | public function __construct( 28 | private string $name, 29 | string|array $froms, 30 | string|array $tos, 31 | ) { 32 | $this->froms = (array) $froms; 33 | $this->tos = (array) $tos; 34 | } 35 | 36 | public function getName(): string 37 | { 38 | return $this->name; 39 | } 40 | 41 | /** 42 | * @return string[] 43 | */ 44 | public function getFroms(): array 45 | { 46 | return $this->froms; 47 | } 48 | 49 | /** 50 | * @return string[] 51 | */ 52 | public function getTos(): array 53 | { 54 | return $this->tos; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Metadata/MetadataStoreInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Metadata; 13 | 14 | use Symfony\Component\Workflow\Transition; 15 | 16 | /** 17 | * MetadataStoreInterface is able to fetch metadata for a specific workflow. 18 | * 19 | * @author Grégoire Pineau 20 | */ 21 | interface MetadataStoreInterface 22 | { 23 | public function getWorkflowMetadata(): array; 24 | 25 | public function getPlaceMetadata(string $place): array; 26 | 27 | public function getTransitionMetadata(Transition $transition): array; 28 | 29 | /** 30 | * Returns the metadata for a specific subject. 31 | * 32 | * This is a proxy method. 33 | * 34 | * @param string|Transition|null $subject Use null to get workflow metadata 35 | * Use a string (the place name) to get place metadata 36 | * Use a Transition instance to get transition metadata 37 | */ 38 | public function getMetadata(string $key, string|Transition|null $subject = null): mixed; 39 | } 40 | -------------------------------------------------------------------------------- /Exception/TransitionException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Exception; 13 | 14 | use Symfony\Component\Workflow\WorkflowInterface; 15 | 16 | /** 17 | * @author Andrew Tch 18 | * @author Grégoire Pineau 19 | */ 20 | class TransitionException extends LogicException 21 | { 22 | public function __construct( 23 | private object $subject, 24 | private string $transitionName, 25 | private WorkflowInterface $workflow, 26 | string $message, 27 | private array $context = [], 28 | ) { 29 | parent::__construct($message); 30 | } 31 | 32 | public function getSubject(): object 33 | { 34 | return $this->subject; 35 | } 36 | 37 | public function getTransitionName(): string 38 | { 39 | return $this->transitionName; 40 | } 41 | 42 | public function getWorkflow(): WorkflowInterface 43 | { 44 | return $this->workflow; 45 | } 46 | 47 | public function getContext(): array 48 | { 49 | return $this->context; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /DependencyInjection/WorkflowDebugPass.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\DependencyInjection; 13 | 14 | use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; 15 | use Symfony\Component\DependencyInjection\ContainerBuilder; 16 | use Symfony\Component\DependencyInjection\Reference; 17 | use Symfony\Component\Workflow\Debug\TraceableWorkflow; 18 | 19 | /** 20 | * Adds all configured security voters to the access decision manager. 21 | * 22 | * @author Grégoire Pineau 23 | */ 24 | class WorkflowDebugPass implements CompilerPassInterface 25 | { 26 | public function process(ContainerBuilder $container): void 27 | { 28 | foreach ($container->findTaggedServiceIds('workflow') as $id => $attributes) { 29 | $container->register("debug.{$id}", TraceableWorkflow::class) 30 | ->setDecoratedService($id) 31 | ->setArguments([ 32 | new Reference("debug.{$id}.inner"), 33 | new Reference('debug.stopwatch'), 34 | new Reference('profiler.is_disabled_state_checker', ContainerBuilder::IGNORE_ON_INVALID_REFERENCE), 35 | ]); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Metadata/InMemoryMetadataStore.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Metadata; 13 | 14 | use Symfony\Component\Workflow\Transition; 15 | 16 | /** 17 | * @author Grégoire Pineau 18 | */ 19 | final class InMemoryMetadataStore implements MetadataStoreInterface 20 | { 21 | use GetMetadataTrait; 22 | 23 | private \SplObjectStorage $transitionsMetadata; 24 | 25 | /** 26 | * @param \SplObjectStorage|null $transitionsMetadata 27 | */ 28 | public function __construct( 29 | private array $workflowMetadata = [], 30 | private array $placesMetadata = [], 31 | ?\SplObjectStorage $transitionsMetadata = null, 32 | ) { 33 | $this->transitionsMetadata = $transitionsMetadata ?? new \SplObjectStorage(); 34 | } 35 | 36 | public function getWorkflowMetadata(): array 37 | { 38 | return $this->workflowMetadata; 39 | } 40 | 41 | public function getPlaceMetadata(string $place): array 42 | { 43 | return $this->placesMetadata[$place] ?? []; 44 | } 45 | 46 | public function getTransitionMetadata(Transition $transition): array 47 | { 48 | return $this->transitionsMetadata[$transition] ?? []; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /DependencyInjection/WorkflowValidatorPass.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\DependencyInjection; 13 | 14 | use Symfony\Component\Config\Resource\FileResource; 15 | use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; 16 | use Symfony\Component\DependencyInjection\ContainerBuilder; 17 | 18 | /** 19 | * @author Grégoire Pineau 20 | */ 21 | class WorkflowValidatorPass implements CompilerPassInterface 22 | { 23 | public function process(ContainerBuilder $container): void 24 | { 25 | foreach ($container->findTaggedServiceIds('workflow') as $attributes) { 26 | foreach ($attributes as $attribute) { 27 | foreach ($attribute['definition_validators'] ?? [] as $validatorClass) { 28 | $container->addResource(new FileResource($container->getReflectionClass($validatorClass)->getFileName())); 29 | 30 | $realDefinition = $container->get($attribute['definition_id'] ?? throw new \LogicException('The "definition_id" attribute is required.')); 31 | (new $validatorClass())->validate($realDefinition, $attribute['name'] ?? throw new \LogicException('The "name" attribute is required.')); 32 | } 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Attribute/AsEnterListener.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Attribute; 13 | 14 | use Symfony\Component\EventDispatcher\Attribute\AsEventListener; 15 | 16 | /** 17 | * Defines a listener for the "enter" event of a workflow. 18 | * 19 | * @author Grégoire Pineau 20 | */ 21 | #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] 22 | final class AsEnterListener extends AsEventListener 23 | { 24 | use BuildEventNameTrait; 25 | 26 | /** 27 | * @param string|null $workflow The id of the workflow to listen to 28 | * @param string|null $place The place name to which the listener listens to 29 | * @param string|null $method The method to run when the listened event is triggered 30 | * @param int $priority The priority of this listener if several are declared for the same place 31 | * @param string|null $dispatcher The service id of the event dispatcher to listen to 32 | */ 33 | public function __construct( 34 | ?string $workflow = null, 35 | ?string $place = null, 36 | ?string $method = null, 37 | int $priority = 0, 38 | ?string $dispatcher = null, 39 | ) { 40 | parent::__construct($this->buildEventName('enter', 'place', $workflow, $place), $method, $priority, $dispatcher); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Attribute/AsLeaveListener.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Attribute; 13 | 14 | use Symfony\Component\EventDispatcher\Attribute\AsEventListener; 15 | 16 | /** 17 | * Defines a listener for the "leave" event of a workflow. 18 | * 19 | * @author Grégoire Pineau 20 | */ 21 | #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] 22 | final class AsLeaveListener extends AsEventListener 23 | { 24 | use BuildEventNameTrait; 25 | 26 | /** 27 | * @param string|null $workflow The id of the workflow to listen to 28 | * @param string|null $place The place name to which the listener listens to 29 | * @param string|null $method The method to run when the listened event is triggered 30 | * @param int $priority The priority of this listener if several are declared for the same place 31 | * @param string|null $dispatcher The service id of the event dispatcher to listen to 32 | */ 33 | public function __construct( 34 | ?string $workflow = null, 35 | ?string $place = null, 36 | ?string $method = null, 37 | int $priority = 0, 38 | ?string $dispatcher = null, 39 | ) { 40 | parent::__construct($this->buildEventName('leave', 'place', $workflow, $place), $method, $priority, $dispatcher); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Attribute/AsEnteredListener.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Attribute; 13 | 14 | use Symfony\Component\EventDispatcher\Attribute\AsEventListener; 15 | 16 | /** 17 | * Defines a listener for the "entered" event of a workflow. 18 | * 19 | * @author Grégoire Pineau 20 | */ 21 | #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] 22 | final class AsEnteredListener extends AsEventListener 23 | { 24 | use BuildEventNameTrait; 25 | 26 | /** 27 | * @param string|null $workflow The id of the workflow to listen to 28 | * @param string|null $place The place name to which the listener listens to 29 | * @param string|null $method The method to run when the listened event is triggered 30 | * @param int $priority The priority of this listener if several are declared for the same place 31 | * @param string|null $dispatcher The service id of the event dispatcher to listen to 32 | */ 33 | public function __construct( 34 | ?string $workflow = null, 35 | ?string $place = null, 36 | ?string $method = null, 37 | int $priority = 0, 38 | ?string $dispatcher = null, 39 | ) { 40 | parent::__construct($this->buildEventName('entered', 'place', $workflow, $place), $method, $priority, $dispatcher); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /DependencyInjection/WorkflowGuardListenerPass.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\DependencyInjection; 13 | 14 | use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; 15 | use Symfony\Component\DependencyInjection\ContainerBuilder; 16 | use Symfony\Component\DependencyInjection\Exception\LogicException; 17 | 18 | /** 19 | * @author Christian Flothmann 20 | * @author Grégoire Pineau 21 | */ 22 | class WorkflowGuardListenerPass implements CompilerPassInterface 23 | { 24 | public function process(ContainerBuilder $container): void 25 | { 26 | if (!$container->hasParameter('workflow.has_guard_listeners')) { 27 | return; 28 | } 29 | 30 | $container->getParameterBag()->remove('workflow.has_guard_listeners'); 31 | 32 | $servicesNeeded = [ 33 | 'security.token_storage', 34 | 'security.authorization_checker', 35 | 'security.authentication.trust_resolver', 36 | 'security.role_hierarchy', 37 | ]; 38 | 39 | foreach ($servicesNeeded as $service) { 40 | if (!$container->has($service)) { 41 | throw new LogicException(\sprintf('The "%s" service is needed to be able to use the workflow guard listener.', $service)); 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Attribute/AsGuardListener.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Attribute; 13 | 14 | use Symfony\Component\EventDispatcher\Attribute\AsEventListener; 15 | 16 | /** 17 | * Defines a listener for a guard event of a workflow. 18 | * 19 | * @author Grégoire Pineau 20 | */ 21 | #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] 22 | final class AsGuardListener extends AsEventListener 23 | { 24 | use BuildEventNameTrait; 25 | 26 | /** 27 | * @param string|null $workflow The id of the workflow to listen to 28 | * @param string|null $transition The transition name to which the listener listens to 29 | * @param string|null $method The method to run when the listened event is triggered 30 | * @param int $priority The priority of this listener if several are declared for the same transition 31 | * @param string|null $dispatcher The service id of the event dispatcher to listen to 32 | */ 33 | public function __construct( 34 | ?string $workflow = null, 35 | ?string $transition = null, 36 | ?string $method = null, 37 | int $priority = 0, 38 | ?string $dispatcher = null, 39 | ) { 40 | parent::__construct($this->buildEventName('guard', 'transition', $workflow, $transition), $method, $priority, $dispatcher); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/workflow", 3 | "type": "library", 4 | "description": "Provides tools for managing a workflow or finite state machine", 5 | "keywords": ["workflow", "petrinet", "place", "transition", "statemachine", "state"], 6 | "homepage": "https://symfony.com", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Fabien Potencier", 11 | "email": "fabien@symfony.com" 12 | }, 13 | { 14 | "name": "Grégoire Pineau", 15 | "email": "lyrixx@lyrixx.info" 16 | }, 17 | { 18 | "name": "Symfony Community", 19 | "homepage": "https://symfony.com/contributors" 20 | } 21 | ], 22 | "require": { 23 | "php": ">=8.2", 24 | "symfony/deprecation-contracts": "2.5|^3" 25 | }, 26 | "require-dev": { 27 | "psr/log": "^1|^2|^3", 28 | "symfony/config": "^6.4|^7.0", 29 | "symfony/dependency-injection": "^6.4|^7.0", 30 | "symfony/error-handler": "^6.4|^7.0", 31 | "symfony/event-dispatcher": "^6.4|^7.0", 32 | "symfony/expression-language": "^6.4|^7.0", 33 | "symfony/http-kernel": "^6.4|^7.0", 34 | "symfony/security-core": "^6.4|^7.0", 35 | "symfony/stopwatch": "^6.4|^7.0", 36 | "symfony/validator": "^6.4|^7.0" 37 | }, 38 | "conflict": { 39 | "symfony/event-dispatcher": "<6.4" 40 | }, 41 | "autoload": { 42 | "psr-4": { "Symfony\\Component\\Workflow\\": "" }, 43 | "exclude-from-classmap": [ 44 | "/Tests/" 45 | ] 46 | }, 47 | "minimum-stability": "dev" 48 | } 49 | -------------------------------------------------------------------------------- /Attribute/AsAnnounceListener.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Attribute; 13 | 14 | use Symfony\Component\EventDispatcher\Attribute\AsEventListener; 15 | 16 | /** 17 | * Defines a listener for the "announce" event of a workflow. 18 | * 19 | * @author Grégoire Pineau 20 | */ 21 | #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] 22 | final class AsAnnounceListener extends AsEventListener 23 | { 24 | use BuildEventNameTrait; 25 | 26 | /** 27 | * @param string|null $workflow The id of the workflow to listen to 28 | * @param string|null $transition The transition name to which the listener listens to 29 | * @param string|null $method The method to run when the listened event is triggered 30 | * @param int $priority The priority of this listener if several are declared for the same transition 31 | * @param string|null $dispatcher The service id of the event dispatcher to listen to 32 | */ 33 | public function __construct( 34 | ?string $workflow = null, 35 | ?string $transition = null, 36 | ?string $method = null, 37 | int $priority = 0, 38 | ?string $dispatcher = null, 39 | ) { 40 | parent::__construct($this->buildEventName('announce', 'transition', $workflow, $transition), $method, $priority, $dispatcher); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Attribute/AsCompletedListener.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Attribute; 13 | 14 | use Symfony\Component\EventDispatcher\Attribute\AsEventListener; 15 | 16 | /** 17 | * Defines a listener for the "completed" event of a workflow. 18 | * 19 | * @author Grégoire Pineau 20 | */ 21 | #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] 22 | final class AsCompletedListener extends AsEventListener 23 | { 24 | use BuildEventNameTrait; 25 | 26 | /** 27 | * @param string|null $workflow The id of the workflow to listen to 28 | * @param string|null $transition The transition name to which the listener listens to 29 | * @param string|null $method The method to run when the listened event is triggered 30 | * @param int $priority The priority of this listener if several are declared for the same transition 31 | * @param string|null $dispatcher The service id of the event dispatcher to listen to 32 | */ 33 | public function __construct( 34 | ?string $workflow = null, 35 | ?string $transition = null, 36 | ?string $method = null, 37 | int $priority = 0, 38 | ?string $dispatcher = null, 39 | ) { 40 | parent::__construct($this->buildEventName('completed', 'transition', $workflow, $transition), $method, $priority, $dispatcher); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Attribute/AsTransitionListener.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Attribute; 13 | 14 | use Symfony\Component\EventDispatcher\Attribute\AsEventListener; 15 | 16 | /** 17 | * Defines a listener for a transition event of a workflow. 18 | * 19 | * @author Grégoire Pineau 20 | */ 21 | #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] 22 | final class AsTransitionListener extends AsEventListener 23 | { 24 | use BuildEventNameTrait; 25 | 26 | /** 27 | * @param string|null $workflow The id of the workflow to listen to 28 | * @param string|null $transition The transition name to which the listener listens to 29 | * @param string|null $method The method to run when the listened event is triggered 30 | * @param int $priority The priority of this listener if several are declared for the same transition 31 | * @param string|null $dispatcher The service id of the event dispatcher to listen to 32 | */ 33 | public function __construct( 34 | ?string $workflow = null, 35 | ?string $transition = null, 36 | ?string $method = null, 37 | int $priority = 0, 38 | ?string $dispatcher = null, 39 | ) { 40 | parent::__construct($this->buildEventName('transition', 'transition', $workflow, $transition), $method, $priority, $dispatcher); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /TransitionBlockerList.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow; 13 | 14 | /** 15 | * A list of transition blockers. 16 | * 17 | * @author Grégoire Pineau 18 | * 19 | * @implements \IteratorAggregate 20 | */ 21 | final class TransitionBlockerList implements \IteratorAggregate, \Countable 22 | { 23 | private array $blockers; 24 | 25 | /** 26 | * @param TransitionBlocker[] $blockers 27 | */ 28 | public function __construct(array $blockers = []) 29 | { 30 | $this->blockers = []; 31 | 32 | foreach ($blockers as $blocker) { 33 | $this->add($blocker); 34 | } 35 | } 36 | 37 | public function add(TransitionBlocker $blocker): void 38 | { 39 | $this->blockers[] = $blocker; 40 | } 41 | 42 | public function has(string $code): bool 43 | { 44 | foreach ($this->blockers as $blocker) { 45 | if ($code === $blocker->getCode()) { 46 | return true; 47 | } 48 | } 49 | 50 | return false; 51 | } 52 | 53 | public function clear(): void 54 | { 55 | $this->blockers = []; 56 | } 57 | 58 | public function isEmpty(): bool 59 | { 60 | return !$this->blockers; 61 | } 62 | 63 | public function getIterator(): \Traversable 64 | { 65 | return new \ArrayIterator($this->blockers); 66 | } 67 | 68 | public function count(): int 69 | { 70 | return \count($this->blockers); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /EventListener/ExpressionLanguage.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\EventListener; 13 | 14 | use Symfony\Component\Security\Core\Authorization\ExpressionLanguage as BaseExpressionLanguage; 15 | use Symfony\Component\Validator\Validator\ValidatorInterface; 16 | use Symfony\Component\Workflow\Exception\RuntimeException; 17 | 18 | /** 19 | * Adds some function to the default Symfony Security ExpressionLanguage. 20 | * 21 | * @author Fabien Potencier 22 | */ 23 | class ExpressionLanguage extends BaseExpressionLanguage 24 | { 25 | protected function registerFunctions(): void 26 | { 27 | parent::registerFunctions(); 28 | 29 | $this->register('is_granted', fn ($attributes, $object = 'null') => \sprintf('$auth_checker->isGranted(%s, %s)', $attributes, $object), fn (array $variables, $attributes, $object = null) => $variables['auth_checker']->isGranted($attributes, $object)); 30 | 31 | $this->register('is_valid', fn ($object = 'null', $groups = 'null') => \sprintf('0 === count($validator->validate(%s, null, %s))', $object, $groups), function (array $variables, $object = null, $groups = null) { 32 | if (!$variables['validator'] instanceof ValidatorInterface) { 33 | throw new RuntimeException('"is_valid" cannot be used as the Validator component is not installed. Try running "composer require symfony/validator".'); 34 | } 35 | 36 | $errors = $variables['validator']->validate($object, null, $groups); 37 | 38 | return 0 === \count($errors); 39 | }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /EventListener/AuditTrailListener.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\EventListener; 13 | 14 | use Psr\Log\LoggerInterface; 15 | use Symfony\Component\EventDispatcher\EventSubscriberInterface; 16 | use Symfony\Component\Workflow\Event\Event; 17 | 18 | /** 19 | * @author Grégoire Pineau 20 | */ 21 | class AuditTrailListener implements EventSubscriberInterface 22 | { 23 | public function __construct( 24 | private LoggerInterface $logger, 25 | ) { 26 | } 27 | 28 | public function onLeave(Event $event): void 29 | { 30 | foreach ($event->getTransition()->getFroms() as $place) { 31 | $this->logger->info(\sprintf('Leaving "%s" for subject of class "%s" in workflow "%s".', $place, $event->getSubject()::class, $event->getWorkflowName())); 32 | } 33 | } 34 | 35 | public function onTransition(Event $event): void 36 | { 37 | $this->logger->info(\sprintf('Transition "%s" for subject of class "%s" in workflow "%s".', $event->getTransition()->getName(), $event->getSubject()::class, $event->getWorkflowName())); 38 | } 39 | 40 | public function onEnter(Event $event): void 41 | { 42 | foreach ($event->getTransition()->getTos() as $place) { 43 | $this->logger->info(\sprintf('Entering "%s" for subject of class "%s" in workflow "%s".', $place, $event->getSubject()::class, $event->getWorkflowName())); 44 | } 45 | } 46 | 47 | public static function getSubscribedEvents(): array 48 | { 49 | return [ 50 | 'workflow.leave' => ['onLeave'], 51 | 'workflow.transition' => ['onTransition'], 52 | 'workflow.enter' => ['onEnter'], 53 | ]; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Event/EventNameTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Event; 13 | 14 | use Symfony\Component\Workflow\Exception\InvalidArgumentException; 15 | 16 | /** 17 | * @author Nicolas Rigaud 18 | * 19 | * @internal 20 | */ 21 | trait EventNameTrait 22 | { 23 | /** 24 | * Gets the event name for workflow and transition. 25 | * 26 | * @throws InvalidArgumentException If $transitionName is provided without $workflowName 27 | */ 28 | private static function getNameForTransition(?string $workflowName, ?string $transitionName): string 29 | { 30 | return self::computeName($workflowName, $transitionName); 31 | } 32 | 33 | /** 34 | * Gets the event name for workflow and place. 35 | * 36 | * @throws InvalidArgumentException If $placeName is provided without $workflowName 37 | */ 38 | private static function getNameForPlace(?string $workflowName, ?string $placeName): string 39 | { 40 | return self::computeName($workflowName, $placeName); 41 | } 42 | 43 | private static function computeName(?string $workflowName, ?string $transitionOrPlaceName): string 44 | { 45 | $eventName = strtolower(basename(str_replace('\\', '/', static::class), 'Event')); 46 | 47 | if (null === $workflowName) { 48 | if (null !== $transitionOrPlaceName) { 49 | throw new \InvalidArgumentException('Missing workflow name.'); 50 | } 51 | 52 | return \sprintf('workflow.%s', $eventName); 53 | } 54 | 55 | if (null === $transitionOrPlaceName) { 56 | return \sprintf('workflow.%s.%s', $workflowName, $eventName); 57 | } 58 | 59 | return \sprintf('workflow.%s.%s.%s', $workflowName, $eventName, $transitionOrPlaceName); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Event/Event.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Event; 13 | 14 | use Symfony\Component\Workflow\Marking; 15 | use Symfony\Component\Workflow\Transition; 16 | use Symfony\Component\Workflow\WorkflowInterface; 17 | use Symfony\Contracts\EventDispatcher\Event as BaseEvent; 18 | 19 | /** 20 | * @author Fabien Potencier 21 | * @author Grégoire Pineau 22 | * @author Carlos Pereira De Amorim 23 | */ 24 | class Event extends BaseEvent 25 | { 26 | public function __construct( 27 | private object $subject, 28 | private Marking $marking, 29 | private ?Transition $transition = null, 30 | private ?WorkflowInterface $workflow = null, 31 | ) { 32 | } 33 | 34 | public function getMarking(): Marking 35 | { 36 | return $this->marking; 37 | } 38 | 39 | public function getSubject(): object 40 | { 41 | return $this->subject; 42 | } 43 | 44 | public function getTransition(): ?Transition 45 | { 46 | return $this->transition; 47 | } 48 | 49 | /** 50 | * @deprecated since Symfony 7.3, inject the workflow in the constructor where you need it 51 | */ 52 | public function getWorkflow(): WorkflowInterface 53 | { 54 | trigger_deprecation('symfony/workflow', '7.3', 'The "%s()" method is deprecated, inject the workflow in the constructor where you need it.', __METHOD__); 55 | 56 | return $this->workflow; 57 | } 58 | 59 | public function getWorkflowName(): string 60 | { 61 | return $this->workflow->getName(); 62 | } 63 | 64 | public function getMetadata(string $key, string|Transition|null $subject): mixed 65 | { 66 | return $this->workflow->getMetadataStore()->getMetadata($key, $subject); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Event/GuardEvent.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Event; 13 | 14 | use Symfony\Component\Workflow\Marking; 15 | use Symfony\Component\Workflow\Transition; 16 | use Symfony\Component\Workflow\TransitionBlocker; 17 | use Symfony\Component\Workflow\TransitionBlockerList; 18 | use Symfony\Component\Workflow\WorkflowInterface; 19 | 20 | /** 21 | * @author Fabien Potencier 22 | * @author Grégoire Pineau 23 | */ 24 | final class GuardEvent extends Event 25 | { 26 | use EventNameTrait { 27 | getNameForTransition as public getName; 28 | } 29 | 30 | private TransitionBlockerList $transitionBlockerList; 31 | 32 | public function __construct(object $subject, Marking $marking, Transition $transition, ?WorkflowInterface $workflow = null) 33 | { 34 | parent::__construct($subject, $marking, $transition, $workflow); 35 | 36 | $this->transitionBlockerList = new TransitionBlockerList(); 37 | } 38 | 39 | public function getTransition(): Transition 40 | { 41 | return parent::getTransition(); 42 | } 43 | 44 | public function isBlocked(): bool 45 | { 46 | return !$this->transitionBlockerList->isEmpty(); 47 | } 48 | 49 | public function setBlocked(bool $blocked, ?string $message = null): void 50 | { 51 | if (!$blocked) { 52 | $this->transitionBlockerList->clear(); 53 | 54 | return; 55 | } 56 | 57 | $this->transitionBlockerList->add(TransitionBlocker::createUnknown($message)); 58 | } 59 | 60 | public function getTransitionBlockerList(): TransitionBlockerList 61 | { 62 | return $this->transitionBlockerList; 63 | } 64 | 65 | public function addTransitionBlocker(TransitionBlocker $transitionBlocker): void 66 | { 67 | $this->transitionBlockerList->add($transitionBlocker); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /WorkflowInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow; 13 | 14 | use Symfony\Component\Workflow\Exception\LogicException; 15 | use Symfony\Component\Workflow\Exception\UndefinedTransitionException; 16 | use Symfony\Component\Workflow\MarkingStore\MarkingStoreInterface; 17 | use Symfony\Component\Workflow\Metadata\MetadataStoreInterface; 18 | 19 | /** 20 | * Describes a workflow instance. 21 | * 22 | * @author Amrouche Hamza 23 | * 24 | * @method Transition|null getEnabledTransition(object $subject, string $name) 25 | */ 26 | interface WorkflowInterface 27 | { 28 | /** 29 | * Returns the object's Marking. 30 | * 31 | * @throws LogicException 32 | */ 33 | public function getMarking(object $subject): Marking; 34 | 35 | /** 36 | * Returns true if the transition is enabled. 37 | */ 38 | public function can(object $subject, string $transitionName): bool; 39 | 40 | /** 41 | * Builds a TransitionBlockerList to know why a transition is blocked. 42 | * 43 | * @throws UndefinedTransitionException If the transition is not defined 44 | */ 45 | public function buildTransitionBlockerList(object $subject, string $transitionName): TransitionBlockerList; 46 | 47 | /** 48 | * Fire a transition. 49 | * 50 | * @throws LogicException If the transition is not applicable 51 | */ 52 | public function apply(object $subject, string $transitionName, array $context = []): Marking; 53 | 54 | /** 55 | * Returns all enabled transitions. 56 | * 57 | * @return Transition[] 58 | */ 59 | public function getEnabledTransitions(object $subject): array; 60 | 61 | public function getName(): string; 62 | 63 | public function getDefinition(): Definition; 64 | 65 | public function getMarkingStore(): MarkingStoreInterface; 66 | 67 | public function getMetadataStore(): MetadataStoreInterface; 68 | } 69 | -------------------------------------------------------------------------------- /Validator/WorkflowValidator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Validator; 13 | 14 | use Symfony\Component\Workflow\Definition; 15 | use Symfony\Component\Workflow\Exception\InvalidDefinitionException; 16 | 17 | /** 18 | * @author Tobias Nyholm 19 | * @author Grégoire Pineau 20 | */ 21 | class WorkflowValidator implements DefinitionValidatorInterface 22 | { 23 | public function __construct( 24 | private bool $singlePlace = false, 25 | ) { 26 | } 27 | 28 | public function validate(Definition $definition, string $name): void 29 | { 30 | // Make sure all transitions for one place has unique name. 31 | $places = array_fill_keys($definition->getPlaces(), []); 32 | foreach ($definition->getTransitions() as $transition) { 33 | foreach ($transition->getFroms() as $from) { 34 | if (\in_array($transition->getName(), $places[$from], true)) { 35 | throw new InvalidDefinitionException(\sprintf('All transitions for a place must have an unique name. Multiple transitions named "%s" where found for place "%s" in workflow "%s".', $transition->getName(), $from, $name)); 36 | } 37 | $places[$from][] = $transition->getName(); 38 | } 39 | } 40 | 41 | if (!$this->singlePlace) { 42 | return; 43 | } 44 | 45 | foreach ($definition->getTransitions() as $transition) { 46 | if (1 < \count($transition->getTos())) { 47 | throw new InvalidDefinitionException(\sprintf('The marking store of workflow "%s" cannot store many places. But the transition "%s" has too many output (%d). Only one is accepted.', $name, $transition->getName(), \count($transition->getTos()))); 48 | } 49 | } 50 | 51 | $initialPlaces = $definition->getInitialPlaces(); 52 | if (2 <= \count($initialPlaces)) { 53 | throw new InvalidDefinitionException(\sprintf('The marking store of workflow "%s" cannot store many places. But the definition has %d initial places. Only one is supported.', $name, \count($initialPlaces))); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Validator/StateMachineValidator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Validator; 13 | 14 | use Symfony\Component\Workflow\Definition; 15 | use Symfony\Component\Workflow\Exception\InvalidDefinitionException; 16 | 17 | /** 18 | * @author Tobias Nyholm 19 | */ 20 | class StateMachineValidator implements DefinitionValidatorInterface 21 | { 22 | public function validate(Definition $definition, string $name): void 23 | { 24 | $transitionFromNames = []; 25 | foreach ($definition->getTransitions() as $transition) { 26 | // Make sure that each transition has exactly one TO 27 | if (1 !== \count($transition->getTos())) { 28 | throw new InvalidDefinitionException(\sprintf('A transition in StateMachine can only have one output. But the transition "%s" in StateMachine "%s" has %d outputs.', $transition->getName(), $name, \count($transition->getTos()))); 29 | } 30 | 31 | // Make sure that each transition has exactly one FROM 32 | $froms = $transition->getFroms(); 33 | if (1 !== \count($froms)) { 34 | throw new InvalidDefinitionException(\sprintf('A transition in StateMachine can only have one input. But the transition "%s" in StateMachine "%s" has %d inputs.', $transition->getName(), $name, \count($froms))); 35 | } 36 | 37 | // Enforcing uniqueness of the names of transitions starting at each node 38 | $from = reset($froms); 39 | if (isset($transitionFromNames[$from][$transition->getName()])) { 40 | throw new InvalidDefinitionException(\sprintf('A transition from a place/state must have an unique name. Multiple transitions named "%s" from place/state "%s" were found on StateMachine "%s".', $transition->getName(), $from, $name)); 41 | } 42 | 43 | $transitionFromNames[$from][$transition->getName()] = true; 44 | } 45 | 46 | $initialPlaces = $definition->getInitialPlaces(); 47 | if (2 <= \count($initialPlaces)) { 48 | throw new InvalidDefinitionException(\sprintf('The state machine "%s" cannot store many places. But the definition has %d initial places. Only one is supported.', $name, \count($initialPlaces))); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /WorkflowEvents.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow; 13 | 14 | use Symfony\Component\Workflow\Event\AnnounceEvent; 15 | use Symfony\Component\Workflow\Event\CompletedEvent; 16 | use Symfony\Component\Workflow\Event\EnteredEvent; 17 | use Symfony\Component\Workflow\Event\EnterEvent; 18 | use Symfony\Component\Workflow\Event\GuardEvent; 19 | use Symfony\Component\Workflow\Event\LeaveEvent; 20 | use Symfony\Component\Workflow\Event\TransitionEvent; 21 | 22 | /** 23 | * To learn more about how workflow events work, check the documentation 24 | * entry at {@link https://symfony.com/doc/current/workflow/usage.html#using-events}. 25 | */ 26 | final class WorkflowEvents 27 | { 28 | /** 29 | * @Event("Symfony\Component\Workflow\Event\GuardEvent") 30 | */ 31 | public const GUARD = 'workflow.guard'; 32 | 33 | /** 34 | * @Event("Symfony\Component\Workflow\Event\LeaveEvent") 35 | */ 36 | public const LEAVE = 'workflow.leave'; 37 | 38 | /** 39 | * @Event("Symfony\Component\Workflow\Event\TransitionEvent") 40 | */ 41 | public const TRANSITION = 'workflow.transition'; 42 | 43 | /** 44 | * @Event("Symfony\Component\Workflow\Event\EnterEvent") 45 | */ 46 | public const ENTER = 'workflow.enter'; 47 | 48 | /** 49 | * @Event("Symfony\Component\Workflow\Event\EnteredEvent") 50 | */ 51 | public const ENTERED = 'workflow.entered'; 52 | 53 | /** 54 | * @Event("Symfony\Component\Workflow\Event\CompletedEvent") 55 | */ 56 | public const COMPLETED = 'workflow.completed'; 57 | 58 | /** 59 | * @Event("Symfony\Component\Workflow\Event\AnnounceEvent") 60 | */ 61 | public const ANNOUNCE = 'workflow.announce'; 62 | 63 | /** 64 | * Event aliases. 65 | * 66 | * These aliases can be consumed by RegisterListenersPass. 67 | */ 68 | public const ALIASES = [ 69 | GuardEvent::class => self::GUARD, 70 | LeaveEvent::class => self::LEAVE, 71 | TransitionEvent::class => self::TRANSITION, 72 | EnterEvent::class => self::ENTER, 73 | EnteredEvent::class => self::ENTERED, 74 | CompletedEvent::class => self::COMPLETED, 75 | AnnounceEvent::class => self::ANNOUNCE, 76 | ]; 77 | 78 | private function __construct() 79 | { 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Registry.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow; 13 | 14 | use Symfony\Component\Workflow\Exception\InvalidArgumentException; 15 | use Symfony\Component\Workflow\SupportStrategy\WorkflowSupportStrategyInterface; 16 | 17 | /** 18 | * @author Fabien Potencier 19 | * @author Grégoire Pineau 20 | */ 21 | class Registry 22 | { 23 | private array $workflows = []; 24 | 25 | public function addWorkflow(WorkflowInterface $workflow, WorkflowSupportStrategyInterface $supportStrategy): void 26 | { 27 | $this->workflows[] = [$workflow, $supportStrategy]; 28 | } 29 | 30 | public function has(object $subject, ?string $workflowName = null): bool 31 | { 32 | foreach ($this->workflows as [$workflow, $supportStrategy]) { 33 | if ($this->supports($workflow, $supportStrategy, $subject, $workflowName)) { 34 | return true; 35 | } 36 | } 37 | 38 | return false; 39 | } 40 | 41 | public function get(object $subject, ?string $workflowName = null): WorkflowInterface 42 | { 43 | $matched = []; 44 | 45 | foreach ($this->workflows as [$workflow, $supportStrategy]) { 46 | if ($this->supports($workflow, $supportStrategy, $subject, $workflowName)) { 47 | $matched[] = $workflow; 48 | } 49 | } 50 | 51 | if (!$matched) { 52 | throw new InvalidArgumentException(\sprintf('Unable to find a workflow for class "%s".', get_debug_type($subject))); 53 | } 54 | 55 | if (2 <= \count($matched)) { 56 | $names = array_map(static fn (WorkflowInterface $workflow): string => $workflow->getName(), $matched); 57 | 58 | throw new InvalidArgumentException(\sprintf('Too many workflows (%s) match this subject (%s); set a different name on each and use the second (name) argument of this method.', implode(', ', $names), get_debug_type($subject))); 59 | } 60 | 61 | return $matched[0]; 62 | } 63 | 64 | /** 65 | * @return Workflow[] 66 | */ 67 | public function all(object $subject): array 68 | { 69 | $matched = []; 70 | foreach ($this->workflows as [$workflow, $supportStrategy]) { 71 | if ($supportStrategy->supports($workflow, $subject)) { 72 | $matched[] = $workflow; 73 | } 74 | } 75 | 76 | return $matched; 77 | } 78 | 79 | private function supports(WorkflowInterface $workflow, WorkflowSupportStrategyInterface $supportStrategy, object $subject, ?string $workflowName): bool 80 | { 81 | if (null !== $workflowName && $workflowName !== $workflow->getName()) { 82 | return false; 83 | } 84 | 85 | return $supportStrategy->supports($workflow, $subject); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Marking.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow; 13 | 14 | /** 15 | * Marking contains the place of every tokens. 16 | * 17 | * @author Grégoire Pineau 18 | */ 19 | class Marking 20 | { 21 | private array $places = []; 22 | private ?array $context = null; 23 | 24 | /** 25 | * @param int[] $representation Keys are the place name and values should be superior or equals to 1 26 | */ 27 | public function __construct(array $representation = []) 28 | { 29 | foreach ($representation as $place => $nbToken) { 30 | $this->mark($place, $nbToken); 31 | } 32 | } 33 | 34 | /** 35 | * @param int $nbToken 36 | * 37 | * @psalm-param int<1, max> $nbToken 38 | */ 39 | public function mark(string $place /* , int $nbToken = 1 */): void 40 | { 41 | $nbToken = 1 < \func_num_args() ? func_get_arg(1) : 1; 42 | 43 | if ($nbToken < 1) { 44 | throw new \InvalidArgumentException(\sprintf('The number of tokens must be greater than 0, "%s" given.', $nbToken)); 45 | } 46 | 47 | $this->places[$place] ??= 0; 48 | $this->places[$place] += $nbToken; 49 | } 50 | 51 | /** 52 | * @param int $nbToken 53 | * 54 | * @psalm-param int<1, max> $nbToken 55 | */ 56 | public function unmark(string $place /* , int $nbToken = 1 */): void 57 | { 58 | $nbToken = 1 < \func_num_args() ? func_get_arg(1) : 1; 59 | 60 | if ($nbToken < 1) { 61 | throw new \InvalidArgumentException(\sprintf('The number of tokens must be greater than 0, "%s" given.', $nbToken)); 62 | } 63 | 64 | if (!$this->has($place)) { 65 | throw new \InvalidArgumentException(\sprintf('The place "%s" is not marked.', $place)); 66 | } 67 | 68 | $tokenCount = $this->places[$place] - $nbToken; 69 | 70 | if (0 > $tokenCount) { 71 | throw new \InvalidArgumentException(\sprintf('The place "%s" could not contain a negative token number: "%s" (initial) - "%s" (nbToken) = "%s".', $place, $this->places[$place], $nbToken, $tokenCount)); 72 | } 73 | 74 | if (0 === $tokenCount) { 75 | unset($this->places[$place]); 76 | } else { 77 | $this->places[$place] = $tokenCount; 78 | } 79 | } 80 | 81 | public function has(string $place): bool 82 | { 83 | return isset($this->places[$place]); 84 | } 85 | 86 | public function getPlaces(): array 87 | { 88 | return $this->places; 89 | } 90 | 91 | /** 92 | * @internal 93 | */ 94 | public function setContext(array $context): void 95 | { 96 | $this->context = $context; 97 | } 98 | 99 | /** 100 | * Returns the context after the subject has transitioned. 101 | */ 102 | public function getContext(): ?array 103 | { 104 | return $this->context; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /TransitionBlocker.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow; 13 | 14 | /** 15 | * A reason why a transition cannot be performed for a subject. 16 | */ 17 | final class TransitionBlocker 18 | { 19 | public const BLOCKED_BY_MARKING = '19beefc8-6b1e-4716-9d07-a39bd6d16e34'; 20 | public const BLOCKED_BY_EXPRESSION_GUARD_LISTENER = '326a1e9c-0c12-11e8-ba89-0ed5f89f718b'; 21 | public const UNKNOWN = 'e8b5bbb9-5913-4b98-bfa6-65dbd228a82a'; 22 | 23 | /** 24 | * @param string $code Code is a machine-readable string, usually an UUID 25 | * @param array $parameters This is useful if you would like to pass around the condition values, that 26 | * blocked the transition. E.g. for a condition "distance must be larger than 27 | * 5 miles", you might want to pass around the value of 5. 28 | */ 29 | public function __construct( 30 | private string $message, 31 | private string $code, 32 | private array $parameters = [], 33 | ) { 34 | } 35 | 36 | /** 37 | * Create a blocker that says the transition cannot be made because it is 38 | * not enabled. 39 | * 40 | * It means the subject is in wrong place (i.e. status): 41 | * * If the workflow is a state machine: the subject is not in the previous place of the transition. 42 | * * If the workflow is a workflow: the subject is not in all previous places of the transition. 43 | */ 44 | public static function createBlockedByMarking(Marking $marking): self 45 | { 46 | return new static('The marking does not enable the transition.', self::BLOCKED_BY_MARKING, [ 47 | 'marking' => $marking, 48 | ]); 49 | } 50 | 51 | /** 52 | * Creates a blocker that says the transition cannot be made because it has 53 | * been blocked by the expression guard listener. 54 | */ 55 | public static function createBlockedByExpressionGuardListener(string $expression): self 56 | { 57 | return new static('The expression blocks the transition.', self::BLOCKED_BY_EXPRESSION_GUARD_LISTENER, [ 58 | 'expression' => $expression, 59 | ]); 60 | } 61 | 62 | /** 63 | * Creates a blocker that says the transition cannot be made because of an 64 | * unknown reason. 65 | */ 66 | public static function createUnknown(?string $message = null, int $backtraceFrame = 2): self 67 | { 68 | if (null !== $message) { 69 | return new static($message, self::UNKNOWN); 70 | } 71 | 72 | $caller = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS, $backtraceFrame + 1)[$backtraceFrame]['class'] ?? null; 73 | 74 | if (null !== $caller) { 75 | return new static("The transition has been blocked by a guard ($caller).", self::UNKNOWN); 76 | } 77 | 78 | return new static('The transition has been blocked by a guard.', self::UNKNOWN); 79 | } 80 | 81 | public function getMessage(): string 82 | { 83 | return $this->message; 84 | } 85 | 86 | public function getCode(): string 87 | { 88 | return $this->code; 89 | } 90 | 91 | public function getParameters(): array 92 | { 93 | return $this->parameters; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /DefinitionBuilder.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow; 13 | 14 | use Symfony\Component\Workflow\Metadata\MetadataStoreInterface; 15 | 16 | /** 17 | * Builds a definition. 18 | * 19 | * @author Fabien Potencier 20 | * @author Grégoire Pineau 21 | * @author Tobias Nyholm 22 | */ 23 | class DefinitionBuilder 24 | { 25 | private array $places = []; 26 | private array $transitions = []; 27 | private string|array|null $initialPlaces = null; 28 | private ?MetadataStoreInterface $metadataStore = null; 29 | 30 | /** 31 | * @param string[] $places 32 | * @param Transition[] $transitions 33 | */ 34 | public function __construct(array $places = [], array $transitions = []) 35 | { 36 | $this->addPlaces($places); 37 | $this->addTransitions($transitions); 38 | } 39 | 40 | public function build(): Definition 41 | { 42 | return new Definition($this->places, $this->transitions, $this->initialPlaces, $this->metadataStore); 43 | } 44 | 45 | /** 46 | * Clear all data in the builder. 47 | * 48 | * @return $this 49 | */ 50 | public function clear(): static 51 | { 52 | $this->places = []; 53 | $this->transitions = []; 54 | $this->initialPlaces = null; 55 | $this->metadataStore = null; 56 | 57 | return $this; 58 | } 59 | 60 | /** 61 | * @param string|string[]|null $initialPlaces 62 | * 63 | * @return $this 64 | */ 65 | public function setInitialPlaces(string|array|null $initialPlaces): static 66 | { 67 | $this->initialPlaces = $initialPlaces; 68 | 69 | return $this; 70 | } 71 | 72 | /** 73 | * @return $this 74 | */ 75 | public function addPlace(string $place): static 76 | { 77 | if (!$this->places) { 78 | $this->initialPlaces = $place; 79 | } 80 | 81 | $this->places[$place] = $place; 82 | 83 | return $this; 84 | } 85 | 86 | /** 87 | * @param string[] $places 88 | * 89 | * @return $this 90 | */ 91 | public function addPlaces(array $places): static 92 | { 93 | foreach ($places as $place) { 94 | $this->addPlace($place); 95 | } 96 | 97 | return $this; 98 | } 99 | 100 | /** 101 | * @param Transition[] $transitions 102 | * 103 | * @return $this 104 | */ 105 | public function addTransitions(array $transitions): static 106 | { 107 | foreach ($transitions as $transition) { 108 | $this->addTransition($transition); 109 | } 110 | 111 | return $this; 112 | } 113 | 114 | /** 115 | * @return $this 116 | */ 117 | public function addTransition(Transition $transition): static 118 | { 119 | $this->transitions[] = $transition; 120 | 121 | return $this; 122 | } 123 | 124 | /** 125 | * @return $this 126 | */ 127 | public function setMetadataStore(MetadataStoreInterface $metadataStore): static 128 | { 129 | $this->metadataStore = $metadataStore; 130 | 131 | return $this; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Dumper/StateMachineGraphvizDumper.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Dumper; 13 | 14 | use Symfony\Component\Workflow\Definition; 15 | use Symfony\Component\Workflow\Marking; 16 | 17 | class StateMachineGraphvizDumper extends GraphvizDumper 18 | { 19 | /** 20 | * Dumps the workflow as a graphviz graph. 21 | * 22 | * Available options: 23 | * 24 | * * graph: The default options for the whole graph 25 | * * node: The default options for nodes (places) 26 | * * edge: The default options for edges 27 | */ 28 | public function dump(Definition $definition, ?Marking $marking = null, array $options = []): string 29 | { 30 | $withMetadata = $options['with-metadata'] ?? false; 31 | 32 | $places = $this->findPlaces($definition, $withMetadata, $marking); 33 | $edges = $this->findEdges($definition); 34 | 35 | $options = array_replace_recursive(self::$defaultOptions, $options); 36 | 37 | $label = $this->formatLabel($definition, $withMetadata, $options); 38 | 39 | return $this->startDot($options, $label) 40 | .$this->addPlaces($places, $withMetadata) 41 | .$this->addEdges($edges) 42 | .$this->endDot(); 43 | } 44 | 45 | /** 46 | * @internal 47 | */ 48 | protected function findEdges(Definition $definition): array 49 | { 50 | $workflowMetadata = $definition->getMetadataStore(); 51 | 52 | $edges = []; 53 | 54 | foreach ($definition->getTransitions() as $transition) { 55 | $attributes = []; 56 | 57 | $transitionName = $workflowMetadata->getMetadata('label', $transition) ?? $transition->getName(); 58 | 59 | $labelColor = $workflowMetadata->getMetadata('color', $transition); 60 | if (null !== $labelColor) { 61 | $attributes['fontcolor'] = $labelColor; 62 | } 63 | $arrowColor = $workflowMetadata->getMetadata('arrow_color', $transition); 64 | if (null !== $arrowColor) { 65 | $attributes['color'] = $arrowColor; 66 | } 67 | 68 | foreach ($transition->getFroms() as $from) { 69 | foreach ($transition->getTos() as $to) { 70 | $edge = [ 71 | 'name' => $transitionName, 72 | 'to' => $to, 73 | 'attributes' => $attributes, 74 | ]; 75 | $edges[$from][] = $edge; 76 | } 77 | } 78 | } 79 | 80 | return $edges; 81 | } 82 | 83 | /** 84 | * @internal 85 | */ 86 | protected function addEdges(array $edges): string 87 | { 88 | $code = ''; 89 | 90 | foreach ($edges as $id => $edges) { 91 | foreach ($edges as $edge) { 92 | $code .= \sprintf( 93 | " place_%s -> place_%s [label=\"%s\" style=\"%s\"%s];\n", 94 | $this->dotize($id), 95 | $this->dotize($edge['to']), 96 | $this->escape($edge['name']), 97 | 'solid', 98 | $this->addAttributes($edge['attributes']) 99 | ); 100 | } 101 | } 102 | 103 | return $code; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Definition.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow; 13 | 14 | use Symfony\Component\Workflow\Exception\LogicException; 15 | use Symfony\Component\Workflow\Metadata\InMemoryMetadataStore; 16 | use Symfony\Component\Workflow\Metadata\MetadataStoreInterface; 17 | 18 | /** 19 | * @author Fabien Potencier 20 | * @author Grégoire Pineau 21 | * @author Tobias Nyholm 22 | */ 23 | final class Definition 24 | { 25 | private array $places = []; 26 | private array $transitions = []; 27 | private array $initialPlaces = []; 28 | private MetadataStoreInterface $metadataStore; 29 | 30 | /** 31 | * @param string[] $places 32 | * @param Transition[] $transitions 33 | * @param string|string[]|null $initialPlaces 34 | */ 35 | public function __construct(array $places, array $transitions, string|array|null $initialPlaces = null, ?MetadataStoreInterface $metadataStore = null) 36 | { 37 | foreach ($places as $place) { 38 | $this->addPlace($place); 39 | } 40 | 41 | foreach ($transitions as $transition) { 42 | $this->addTransition($transition); 43 | } 44 | 45 | $this->setInitialPlaces($initialPlaces); 46 | 47 | $this->metadataStore = $metadataStore ?? new InMemoryMetadataStore(); 48 | } 49 | 50 | /** 51 | * @return string[] 52 | */ 53 | public function getInitialPlaces(): array 54 | { 55 | return $this->initialPlaces; 56 | } 57 | 58 | /** 59 | * @return string[] 60 | */ 61 | public function getPlaces(): array 62 | { 63 | return $this->places; 64 | } 65 | 66 | /** 67 | * @return Transition[] 68 | */ 69 | public function getTransitions(): array 70 | { 71 | return $this->transitions; 72 | } 73 | 74 | public function getMetadataStore(): MetadataStoreInterface 75 | { 76 | return $this->metadataStore; 77 | } 78 | 79 | private function setInitialPlaces(string|array|null $places): void 80 | { 81 | if (!$places) { 82 | return; 83 | } 84 | 85 | $places = (array) $places; 86 | 87 | foreach ($places as $place) { 88 | if (!isset($this->places[$place])) { 89 | throw new LogicException(\sprintf('Place "%s" cannot be the initial place as it does not exist.', $place)); 90 | } 91 | } 92 | 93 | $this->initialPlaces = $places; 94 | } 95 | 96 | private function addPlace(string $place): void 97 | { 98 | if (!\count($this->places)) { 99 | $this->initialPlaces = [$place]; 100 | } 101 | 102 | $this->places[$place] = $place; 103 | } 104 | 105 | private function addTransition(Transition $transition): void 106 | { 107 | foreach ($transition->getFroms() as $from) { 108 | if (!\array_key_exists($from, $this->places)) { 109 | $this->addPlace($from); 110 | } 111 | } 112 | 113 | foreach ($transition->getTos() as $to) { 114 | if (!\array_key_exists($to, $this->places)) { 115 | $this->addPlace($to); 116 | } 117 | } 118 | 119 | $this->transitions[] = $transition; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /EventListener/GuardListener.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\EventListener; 13 | 14 | use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface; 15 | use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; 16 | use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; 17 | use Symfony\Component\Security\Core\Role\RoleHierarchyInterface; 18 | use Symfony\Component\Validator\Validator\ValidatorInterface; 19 | use Symfony\Component\Workflow\Event\GuardEvent; 20 | use Symfony\Component\Workflow\TransitionBlocker; 21 | 22 | /** 23 | * @author Grégoire Pineau 24 | */ 25 | class GuardListener 26 | { 27 | public function __construct( 28 | private array $configuration, 29 | private ExpressionLanguage $expressionLanguage, 30 | private TokenStorageInterface $tokenStorage, 31 | private AuthorizationCheckerInterface $authorizationChecker, 32 | private AuthenticationTrustResolverInterface $trustResolver, 33 | private ?RoleHierarchyInterface $roleHierarchy = null, 34 | private ?ValidatorInterface $validator = null, 35 | ) { 36 | } 37 | 38 | public function onTransition(GuardEvent $event, string $eventName): void 39 | { 40 | if (!isset($this->configuration[$eventName])) { 41 | return; 42 | } 43 | 44 | $eventConfiguration = (array) $this->configuration[$eventName]; 45 | foreach ($eventConfiguration as $guard) { 46 | if ($guard instanceof GuardExpression) { 47 | if ($guard->getTransition() !== $event->getTransition()) { 48 | continue; 49 | } 50 | $this->validateGuardExpression($event, $guard->getExpression()); 51 | } else { 52 | $this->validateGuardExpression($event, $guard); 53 | } 54 | } 55 | } 56 | 57 | private function validateGuardExpression(GuardEvent $event, string $expression): void 58 | { 59 | if (!$this->expressionLanguage->evaluate($expression, $this->getVariables($event))) { 60 | $blocker = TransitionBlocker::createBlockedByExpressionGuardListener($expression); 61 | $event->addTransitionBlocker($blocker); 62 | } 63 | } 64 | 65 | // code should be sync with Symfony\Component\Security\Core\Authorization\Voter\ExpressionVoter 66 | private function getVariables(GuardEvent $event): array 67 | { 68 | $token = $this->tokenStorage->getToken(); 69 | 70 | $variables = [ 71 | 'subject' => $event->getSubject(), 72 | // needed for the is_granted expression function 73 | 'auth_checker' => $this->authorizationChecker, 74 | // needed for the is_* expression function 75 | 'trust_resolver' => $this->trustResolver, 76 | // needed for the is_valid expression function 77 | 'validator' => $this->validator, 78 | ]; 79 | 80 | if (null === $token) { 81 | return $variables + [ 82 | 'token' => null, 83 | 'user' => null, 84 | 'role_names' => [], 85 | ]; 86 | } 87 | 88 | return $variables + [ 89 | 'token' => $token, 90 | 'user' => $token->getUser(), 91 | 'role_names' => $this->roleHierarchy->getReachableRoleNames($token->getRoleNames()), 92 | ]; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Debug/TraceableWorkflow.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Debug; 13 | 14 | use Symfony\Component\Stopwatch\Stopwatch; 15 | use Symfony\Component\Workflow\Definition; 16 | use Symfony\Component\Workflow\Marking; 17 | use Symfony\Component\Workflow\MarkingStore\MarkingStoreInterface; 18 | use Symfony\Component\Workflow\Metadata\MetadataStoreInterface; 19 | use Symfony\Component\Workflow\Transition; 20 | use Symfony\Component\Workflow\TransitionBlockerList; 21 | use Symfony\Component\Workflow\WorkflowInterface; 22 | 23 | /** 24 | * @author Grégoire Pineau 25 | */ 26 | class TraceableWorkflow implements WorkflowInterface 27 | { 28 | private array $calls = []; 29 | 30 | public function __construct( 31 | private readonly WorkflowInterface $workflow, 32 | private readonly Stopwatch $stopwatch, 33 | protected readonly ?\Closure $disabled = null, 34 | ) { 35 | } 36 | 37 | public function getMarking(object $subject, array $context = []): Marking 38 | { 39 | return $this->callInner(__FUNCTION__, \func_get_args()); 40 | } 41 | 42 | public function can(object $subject, string $transitionName): bool 43 | { 44 | return $this->callInner(__FUNCTION__, \func_get_args()); 45 | } 46 | 47 | public function buildTransitionBlockerList(object $subject, string $transitionName): TransitionBlockerList 48 | { 49 | return $this->callInner(__FUNCTION__, \func_get_args()); 50 | } 51 | 52 | public function apply(object $subject, string $transitionName, array $context = []): Marking 53 | { 54 | return $this->callInner(__FUNCTION__, \func_get_args()); 55 | } 56 | 57 | public function getEnabledTransitions(object $subject): array 58 | { 59 | return $this->callInner(__FUNCTION__, \func_get_args()); 60 | } 61 | 62 | public function getEnabledTransition(object $subject, string $name): ?Transition 63 | { 64 | return $this->callInner(__FUNCTION__, \func_get_args()); 65 | } 66 | 67 | public function getName(): string 68 | { 69 | return $this->workflow->getName(); 70 | } 71 | 72 | public function getDefinition(): Definition 73 | { 74 | return $this->workflow->getDefinition(); 75 | } 76 | 77 | public function getMarkingStore(): MarkingStoreInterface 78 | { 79 | return $this->workflow->getMarkingStore(); 80 | } 81 | 82 | public function getMetadataStore(): MetadataStoreInterface 83 | { 84 | return $this->workflow->getMetadataStore(); 85 | } 86 | 87 | public function getCalls(): array 88 | { 89 | return $this->calls; 90 | } 91 | 92 | public function getInner(): WorkflowInterface 93 | { 94 | return $this->workflow; 95 | } 96 | 97 | private function callInner(string $method, array $args): mixed 98 | { 99 | if ($this->disabled?->__invoke()) { 100 | return $this->workflow->{$method}(...$args); 101 | } 102 | $sMethod = $this->workflow::class.'::'.$method; 103 | $this->stopwatch->start($sMethod, 'workflow'); 104 | 105 | $previousMarking = null; 106 | if ('apply' === $method) { 107 | try { 108 | $previousMarking = $this->workflow->getMarking($args[0]); 109 | } catch (\Throwable) { 110 | } 111 | } 112 | 113 | try { 114 | $return = $this->workflow->{$method}(...$args); 115 | 116 | $this->calls[] = [ 117 | 'method' => $method, 118 | 'duration' => $this->stopwatch->stop($sMethod)->getDuration(), 119 | 'args' => $args, 120 | 'previousMarking' => $previousMarking ?? null, 121 | 'return' => $return, 122 | ]; 123 | 124 | return $return; 125 | } catch (\Throwable $exception) { 126 | $this->calls[] = [ 127 | 'method' => $method, 128 | 'duration' => $this->stopwatch->stop($sMethod)->getDuration(), 129 | 'args' => $args, 130 | 'previousMarking' => $previousMarking ?? null, 131 | 'exception' => $exception, 132 | ]; 133 | 134 | throw $exception; 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /MarkingStore/MethodMarkingStore.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\MarkingStore; 13 | 14 | use Symfony\Component\Workflow\Exception\LogicException; 15 | use Symfony\Component\Workflow\Marking; 16 | 17 | /** 18 | * MethodMarkingStore stores the marking with a subject's public method 19 | * or public property. 20 | * 21 | * This store deals with a "single state" or "multiple state" marking. 22 | * 23 | * "single state" marking means a subject can be in one and only one state at 24 | * the same time. Use it with state machine. It uses a string to store the 25 | * marking. 26 | * 27 | * "multiple state" marking means a subject can be in many states at the same 28 | * time. Use it with workflow. It uses an array of strings to store the marking. 29 | * 30 | * @author Grégoire Pineau 31 | */ 32 | final class MethodMarkingStore implements MarkingStoreInterface 33 | { 34 | /** @var array */ 35 | private array $getters = []; 36 | /** @var array */ 37 | private array $setters = []; 38 | 39 | /** 40 | * @param string $property Used to determine methods or property to call 41 | * The `getMarking` method will use `$subject->getProperty()` or `$subject->property` 42 | * The `setMarking` method will use `$subject->setProperty(string|array $places, array $context = [])` or `$subject->property = string|array $places` 43 | */ 44 | public function __construct( 45 | private bool $singleState = false, 46 | private string $property = 'marking', 47 | ) { 48 | } 49 | 50 | public function getMarking(object $subject): Marking 51 | { 52 | $marking = null; 53 | try { 54 | $marking = ($this->getGetter($subject))(); 55 | } catch (\Error $e) { 56 | if (!str_starts_with($e->getMessage(), 'Typed property ') || !str_ends_with($e->getMessage(), '::$'.$this->property.' must not be accessed before initialization')) { 57 | throw $e; 58 | } 59 | } 60 | 61 | if (null === $marking) { 62 | return new Marking(); 63 | } 64 | 65 | if ($this->singleState) { 66 | $marking = [(string) $marking => 1]; 67 | } elseif (!\is_array($marking)) { 68 | throw new LogicException(\sprintf('The marking stored in "%s::$%s" is not an array and the Workflow\'s Marking store is instantiated with $singleState=false.', get_debug_type($subject), $this->property)); 69 | } 70 | 71 | return new Marking($marking); 72 | } 73 | 74 | public function setMarking(object $subject, Marking $marking, array $context = []): void 75 | { 76 | $marking = $marking->getPlaces(); 77 | 78 | if ($this->singleState) { 79 | $marking = key($marking); 80 | } 81 | 82 | ($this->getSetter($subject))($marking, $context); 83 | } 84 | 85 | private function getGetter(object $subject): callable 86 | { 87 | $property = $this->property; 88 | $method = 'get'.ucfirst($property); 89 | 90 | return match ($this->getters[$subject::class] ??= self::getType($subject, $property, $method)) { 91 | MarkingStoreMethod::METHOD => $subject->{$method}(...), 92 | MarkingStoreMethod::PROPERTY => static fn () => $subject->{$property}, 93 | }; 94 | } 95 | 96 | private function getSetter(object $subject): callable 97 | { 98 | $property = $this->property; 99 | $method = 'set'.ucfirst($property); 100 | 101 | return match ($this->setters[$subject::class] ??= self::getType($subject, $property, $method)) { 102 | MarkingStoreMethod::METHOD => $subject->{$method}(...), 103 | MarkingStoreMethod::PROPERTY => static fn ($marking) => $subject->{$property} = $marking, 104 | }; 105 | } 106 | 107 | private static function getType(object $subject, string $property, string $method): MarkingStoreMethod 108 | { 109 | if (method_exists($subject, $method) && (new \ReflectionMethod($subject, $method))->isPublic()) { 110 | return MarkingStoreMethod::METHOD; 111 | } 112 | 113 | try { 114 | if ((new \ReflectionProperty($subject, $property))->isPublic()) { 115 | return MarkingStoreMethod::PROPERTY; 116 | } 117 | } catch (\ReflectionException) { 118 | } 119 | 120 | throw new LogicException(\sprintf('Cannot store marking: class "%s" should have either a public method named "%s()" or a public property named "$%s"; none found.', get_debug_type($subject), $method, $property)); 121 | } 122 | } 123 | 124 | /** 125 | * @internal 126 | */ 127 | enum MarkingStoreMethod 128 | { 129 | case METHOD; 130 | case PROPERTY; 131 | } 132 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | 7.3 5 | --- 6 | 7 | * Deprecate `Event::getWorkflow()` method 8 | 9 | 7.1 10 | --- 11 | 12 | * Add method `getEnabledTransition()` to `WorkflowInterface` 13 | * Automatically register places from transitions 14 | * Add support for workflows that need to store many tokens in the marking 15 | * Add method `getName()` in event classes to build event names in subscribers 16 | 17 | 7.0 18 | --- 19 | 20 | * Require explicit argument when calling `Definition::setInitialPlaces()` 21 | * `GuardEvent::getContext()` method has been removed. Method was not supposed to be called within guard event listeners as it always returned an empty array anyway. 22 | * Remove `GuardEvent::getContext()` method without replacement 23 | 24 | 6.4 25 | --- 26 | 27 | * Add `with-metadata` option to the `workflow:dump` command to include places, 28 | transitions and workflow's metadata into dumped graph 29 | * Add support for storing marking in a property 30 | * Add a profiler 31 | * Add support for multiline descriptions in PlantUML diagrams 32 | * Add PHP attributes to register listeners and guards 33 | * Deprecate `GuardEvent::getContext()` method that will be removed in 7.0 34 | * Revert: Mark `Symfony\Component\Workflow\Registry` as internal 35 | * Add `WorkflowGuardListenerPass` (moved from `FrameworkBundle`) 36 | 37 | 6.2 38 | --- 39 | 40 | * Mark `Symfony\Component\Workflow\Registry` as internal 41 | * Deprecate calling `Definition::setInitialPlaces()` without arguments 42 | 43 | 6.0 44 | --- 45 | 46 | * Remove `InvalidTokenConfigurationException` 47 | 48 | 5.4 49 | --- 50 | 51 | * Add support for getting updated context after a transition 52 | 53 | 5.3 54 | --- 55 | 56 | * Deprecate `InvalidTokenConfigurationException` 57 | * Added `MermaidDumper` to dump Workflow graphs in the Mermaid.js flowchart format 58 | 59 | 5.2.0 60 | ----- 61 | 62 | * Added `Workflow::getEnabledTransition()` to easily retrieve a specific transition object 63 | * Added context to the event dispatched 64 | * Dispatch an event when the subject enters in the workflow for the very first time 65 | * Added a default context to the previous event 66 | * Added support for specifying which events should be dispatched when calling `workflow->apply()` 67 | 68 | 5.1.0 69 | ----- 70 | 71 | * Added context to `TransitionException` and its child classes whenever they are thrown in `Workflow::apply()` 72 | * Added `Registry::has()` to check if a workflow exists 73 | * Added support for `$context[Workflow::DISABLE_ANNOUNCE_EVENT] = true` when calling `workflow->apply()` to not fire the announce event 74 | 75 | 5.0.0 76 | ----- 77 | 78 | * Added argument `$context` to `MarkingStoreInterface::setMarking()` 79 | 80 | 4.4.0 81 | ----- 82 | 83 | * Marked all dispatched event classes as `@final` 84 | 85 | 4.3.0 86 | ----- 87 | 88 | * Trigger `entered` event for subject entering in the Workflow for the first time. 89 | * Added a context to `Workflow::apply()`. The `MethodMarkingStore` could be used to leverage this feature. 90 | * The `TransitionEvent` is able to modify the context. 91 | * Add style to transitions by declaring metadata: 92 | 93 | use Symfony\Component\Workflow\Definition; 94 | use Symfony\Component\Workflow\Metadata\InMemoryMetadataStore; 95 | 96 | $transitionsMetadata = new \SplObjectStorage(); 97 | $transitionsMetadata[$transition] = [ 98 | 'color' => 'Red', 99 | 'arrow_color' => '#00ff00', 100 | ]; 101 | $inMemoryMetadataStore = new InMemoryMetadataStore([], [], $transitionsMetadata); 102 | 103 | return new Definition($places, $transitions, null, $inMemoryMetadataStore); 104 | * Dispatch `GuardEvent` on `workflow.guard` 105 | * Dispatch `LeaveEvent` on `workflow.leave` 106 | * Dispatch `TransitionEvent` on `workflow.transition` 107 | * Dispatch `EnterEvent` on `workflow.enter` 108 | * Dispatch `EnteredEvent` on `workflow.entered` 109 | * Dispatch `CompletedEvent` on `workflow.completed` 110 | * Dispatch `AnnounceEvent` on `workflow.announce` 111 | * Added support for many `initialPlaces` 112 | * Deprecated `DefinitionBuilder::setInitialPlace()` method, use `DefinitionBuilder::setInitialPlaces()` instead. 113 | * Deprecated the `MultipleStateMarkingStore` class, use the `MethodMarkingStore` instead. 114 | * Deprecated the `SingleStateMarkingStore` class, use the `MethodMarkingStore` instead. 115 | 116 | 4.1.0 117 | ----- 118 | 119 | * Deprecated the `DefinitionBuilder::reset()` method, use the `clear()` one instead. 120 | * Deprecated the usage of `add(Workflow $workflow, $supportStrategy)` in `Workflow/Registry`, use `addWorkflow(WorkflowInterface, $supportStrategy)` instead. 121 | * Deprecated the usage of `SupportStrategyInterface`, use `WorkflowSupportStrategyInterface` instead. 122 | * The `Workflow` class now implements `WorkflowInterface`. 123 | * Deprecated the class `ClassInstanceSupportStrategy` in favor of the class `InstanceOfSupportStrategy`. 124 | * Added TransitionBlockers as a way to pass around reasons why exactly 125 | transitions can't be made. 126 | * Added a `MetadataStore`. 127 | * Added `Registry::all` to return all the workflows associated with the 128 | specific subject. 129 | 130 | 4.0.0 131 | ----- 132 | 133 | * Removed class name support in `WorkflowRegistry::add()` as second parameter. 134 | 135 | 3.4.0 136 | ----- 137 | 138 | * Added guard `is_valid()` method support. 139 | * Added support for `Event::getWorkflowName()` for "announce" events. 140 | * Added `workflow.completed` events which are fired after a transition is completed. 141 | 142 | 3.3.0 143 | ----- 144 | 145 | * Added support for expressions to guard transitions and added an `is_granted()` 146 | function that can be used in these expressions to use the authorization checker. 147 | * The `DefinitionBuilder` class now provides a fluent interface. 148 | * The `AuditTrailListener` now includes the workflow name in its log entries. 149 | * Added `workflow.entered` events which is fired after the marking has been set. 150 | * Deprecated class name support in `WorkflowRegistry::add()` as second parameter. 151 | Wrap the class name in an instance of ClassInstanceSupportStrategy instead. 152 | * Added support for `Event::getWorkflowName()`. 153 | * Added `SupportStrategyInterface` to allow custom strategies to decide whether 154 | or not a workflow supports a subject. 155 | * Added `ValidateWorkflowPass`. 156 | -------------------------------------------------------------------------------- /Dumper/MermaidDumper.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Dumper; 13 | 14 | use Symfony\Component\Workflow\Definition; 15 | use Symfony\Component\Workflow\Exception\InvalidArgumentException; 16 | use Symfony\Component\Workflow\Marking; 17 | 18 | class MermaidDumper implements DumperInterface 19 | { 20 | public const DIRECTION_TOP_TO_BOTTOM = 'TB'; 21 | public const DIRECTION_TOP_DOWN = 'TD'; 22 | public const DIRECTION_BOTTOM_TO_TOP = 'BT'; 23 | public const DIRECTION_RIGHT_TO_LEFT = 'RL'; 24 | public const DIRECTION_LEFT_TO_RIGHT = 'LR'; 25 | 26 | private const VALID_DIRECTIONS = [ 27 | self::DIRECTION_TOP_TO_BOTTOM, 28 | self::DIRECTION_TOP_DOWN, 29 | self::DIRECTION_BOTTOM_TO_TOP, 30 | self::DIRECTION_RIGHT_TO_LEFT, 31 | self::DIRECTION_LEFT_TO_RIGHT, 32 | ]; 33 | 34 | public const TRANSITION_TYPE_STATEMACHINE = 'statemachine'; 35 | public const TRANSITION_TYPE_WORKFLOW = 'workflow'; 36 | 37 | private const VALID_TRANSITION_TYPES = [ 38 | self::TRANSITION_TYPE_STATEMACHINE, 39 | self::TRANSITION_TYPE_WORKFLOW, 40 | ]; 41 | 42 | /** 43 | * Just tracking the transition id is in some cases inaccurate to 44 | * get the link's number for styling purposes. 45 | */ 46 | private int $linkCount = 0; 47 | 48 | public function __construct( 49 | private string $transitionType, 50 | private string $direction = self::DIRECTION_LEFT_TO_RIGHT, 51 | ) { 52 | $this->validateDirection($direction); 53 | $this->validateTransitionType($transitionType); 54 | } 55 | 56 | public function dump(Definition $definition, ?Marking $marking = null, array $options = []): string 57 | { 58 | $this->linkCount = 0; 59 | $placeNameMap = []; 60 | $placeId = 0; 61 | 62 | $output = ['graph '.$this->direction]; 63 | 64 | $meta = $definition->getMetadataStore(); 65 | 66 | foreach ($definition->getPlaces() as $place) { 67 | [$placeNodeName, $placeNode, $placeStyle] = $this->preparePlace( 68 | $placeId, 69 | $place, 70 | $meta->getPlaceMetadata($place), 71 | \in_array($place, $definition->getInitialPlaces(), true), 72 | $marking?->has($place) ?? false 73 | ); 74 | 75 | $output[] = $placeNode; 76 | 77 | if ('' !== $placeStyle) { 78 | $output[] = $placeStyle; 79 | } 80 | 81 | $placeNameMap[$place] = $placeNodeName; 82 | 83 | ++$placeId; 84 | } 85 | 86 | foreach ($definition->getTransitions() as $transitionId => $transition) { 87 | $transitionMeta = $meta->getTransitionMetadata($transition); 88 | 89 | $transitionLabel = $transition->getName(); 90 | if (\array_key_exists('label', $transitionMeta)) { 91 | $transitionLabel = $transitionMeta['label']; 92 | } 93 | 94 | foreach ($transition->getFroms() as $from) { 95 | $from = $placeNameMap[$from]; 96 | 97 | foreach ($transition->getTos() as $to) { 98 | $to = $placeNameMap[$to]; 99 | 100 | if (self::TRANSITION_TYPE_STATEMACHINE === $this->transitionType) { 101 | $transitionOutput = $this->styleStateMachineTransition($from, $to, $transitionLabel, $transitionMeta); 102 | } else { 103 | $transitionOutput = $this->styleWorkflowTransition($from, $to, $transitionId, $transitionLabel, $transitionMeta); 104 | } 105 | 106 | foreach ($transitionOutput as $line) { 107 | if (\in_array($line, $output)) { 108 | // additional links must be decremented again to align the styling 109 | if (0 < strpos($line, '-->')) { 110 | --$this->linkCount; 111 | } 112 | 113 | continue; 114 | } 115 | 116 | $output[] = $line; 117 | } 118 | } 119 | } 120 | } 121 | 122 | return implode("\n", $output); 123 | } 124 | 125 | private function preparePlace(int $placeId, string $placeName, array $meta, bool $isInitial, bool $hasMarking): array 126 | { 127 | $placeLabel = $placeName; 128 | if (\array_key_exists('label', $meta)) { 129 | $placeLabel = $meta['label']; 130 | } 131 | 132 | $placeLabel = $this->escape($placeLabel); 133 | 134 | $labelShape = '((%s))'; 135 | if ($isInitial) { 136 | $labelShape = '([%s])'; 137 | } 138 | 139 | $placeNodeName = 'place'.$placeId; 140 | $placeNodeFormat = '%s'.$labelShape; 141 | $placeNode = \sprintf($placeNodeFormat, $placeNodeName, $placeLabel); 142 | 143 | $placeStyle = $this->styleNode($meta, $placeNodeName, $hasMarking); 144 | 145 | return [$placeNodeName, $placeNode, $placeStyle]; 146 | } 147 | 148 | private function styleNode(array $meta, string $nodeName, bool $hasMarking = false): string 149 | { 150 | $nodeStyles = []; 151 | 152 | if (\array_key_exists('bg_color', $meta)) { 153 | $nodeStyles[] = \sprintf( 154 | 'fill:%s', 155 | $meta['bg_color'] 156 | ); 157 | } 158 | 159 | if ($hasMarking) { 160 | $nodeStyles[] = 'stroke-width:4px'; 161 | } 162 | 163 | if (0 === \count($nodeStyles)) { 164 | return ''; 165 | } 166 | 167 | return \sprintf('style %s %s', $nodeName, implode(',', $nodeStyles)); 168 | } 169 | 170 | /** 171 | * Replace double quotes with the mermaid escape syntax and 172 | * ensure all other characters are properly escaped. 173 | */ 174 | private function escape(string $label): string 175 | { 176 | $label = str_replace('"', '#quot;', $label); 177 | 178 | return \sprintf('"%s"', $label); 179 | } 180 | 181 | public function validateDirection(string $direction): void 182 | { 183 | if (!\in_array($direction, self::VALID_DIRECTIONS, true)) { 184 | throw new InvalidArgumentException(\sprintf('Direction "%s" is not valid, valid directions are: "%s".', $direction, implode(', ', self::VALID_DIRECTIONS))); 185 | } 186 | } 187 | 188 | private function validateTransitionType(string $transitionType): void 189 | { 190 | if (!\in_array($transitionType, self::VALID_TRANSITION_TYPES, true)) { 191 | throw new InvalidArgumentException(\sprintf('Transition type "%s" is not valid, valid types are: "%s".', $transitionType, implode(', ', self::VALID_TRANSITION_TYPES))); 192 | } 193 | } 194 | 195 | private function styleStateMachineTransition(string $from, string $to, string $transitionLabel, array $transitionMeta): array 196 | { 197 | $transitionOutput = [\sprintf('%s-->|%s|%s', $from, str_replace("\n", ' ', $this->escape($transitionLabel)), $to)]; 198 | 199 | $linkStyle = $this->styleLink($transitionMeta); 200 | if ('' !== $linkStyle) { 201 | $transitionOutput[] = $linkStyle; 202 | } 203 | 204 | ++$this->linkCount; 205 | 206 | return $transitionOutput; 207 | } 208 | 209 | private function styleWorkflowTransition(string $from, string $to, int $transitionId, string $transitionLabel, array $transitionMeta): array 210 | { 211 | $transitionOutput = []; 212 | 213 | $transitionLabel = $this->escape($transitionLabel); 214 | $transitionNodeName = 'transition'.$transitionId; 215 | 216 | $transitionOutput[] = \sprintf('%s[%s]', $transitionNodeName, $transitionLabel); 217 | 218 | $transitionNodeStyle = $this->styleNode($transitionMeta, $transitionNodeName); 219 | if ('' !== $transitionNodeStyle) { 220 | $transitionOutput[] = $transitionNodeStyle; 221 | } 222 | 223 | $connectionStyle = '%s-->%s'; 224 | $transitionOutput[] = \sprintf($connectionStyle, $from, $transitionNodeName); 225 | 226 | $linkStyle = $this->styleLink($transitionMeta); 227 | if ('' !== $linkStyle) { 228 | $transitionOutput[] = $linkStyle; 229 | } 230 | 231 | ++$this->linkCount; 232 | 233 | $transitionOutput[] = \sprintf($connectionStyle, $transitionNodeName, $to); 234 | 235 | $linkStyle = $this->styleLink($transitionMeta); 236 | if ('' !== $linkStyle) { 237 | $transitionOutput[] = $linkStyle; 238 | } 239 | 240 | ++$this->linkCount; 241 | 242 | return $transitionOutput; 243 | } 244 | 245 | private function styleLink(array $transitionMeta): string 246 | { 247 | if (\array_key_exists('color', $transitionMeta)) { 248 | return \sprintf('linkStyle %d stroke:%s', $this->linkCount, $transitionMeta['color']); 249 | } 250 | 251 | return ''; 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /DataCollector/WorkflowDataCollector.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\DataCollector; 13 | 14 | use Symfony\Component\ErrorHandler\ErrorRenderer\FileLinkFormatter; 15 | use Symfony\Component\EventDispatcher\EventDispatcherInterface; 16 | use Symfony\Component\HttpFoundation\Request; 17 | use Symfony\Component\HttpFoundation\Response; 18 | use Symfony\Component\HttpKernel\DataCollector\DataCollector; 19 | use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface; 20 | use Symfony\Component\VarDumper\Caster\Caster; 21 | use Symfony\Component\VarDumper\Cloner\Stub; 22 | use Symfony\Component\Workflow\Debug\TraceableWorkflow; 23 | use Symfony\Component\Workflow\Dumper\MermaidDumper; 24 | use Symfony\Component\Workflow\EventListener\GuardExpression; 25 | use Symfony\Component\Workflow\EventListener\GuardListener; 26 | use Symfony\Component\Workflow\Marking; 27 | use Symfony\Component\Workflow\Transition; 28 | use Symfony\Component\Workflow\TransitionBlocker; 29 | use Symfony\Component\Workflow\WorkflowInterface; 30 | 31 | /** 32 | * @author Grégoire Pineau 33 | */ 34 | final class WorkflowDataCollector extends DataCollector implements LateDataCollectorInterface 35 | { 36 | public function __construct( 37 | private readonly iterable $workflows, 38 | private readonly EventDispatcherInterface $eventDispatcher, 39 | private readonly FileLinkFormatter $fileLinkFormatter, 40 | ) { 41 | } 42 | 43 | public function collect(Request $request, Response $response, ?\Throwable $exception = null): void 44 | { 45 | } 46 | 47 | public function lateCollect(): void 48 | { 49 | foreach ($this->workflows as $workflow) { 50 | $calls = []; 51 | if ($workflow instanceof TraceableWorkflow) { 52 | $calls = $this->cloneVar($workflow->getCalls()); 53 | } 54 | 55 | // We always use a workflow type because we want to mermaid to 56 | // create a node for transitions 57 | $dumper = new MermaidDumper(MermaidDumper::TRANSITION_TYPE_WORKFLOW); 58 | $this->data['workflows'][$workflow->getName()] = [ 59 | 'dump' => $dumper->dump($workflow->getDefinition()), 60 | 'calls' => $calls, 61 | 'listeners' => $this->getEventListeners($workflow), 62 | ]; 63 | } 64 | } 65 | 66 | public function getName(): string 67 | { 68 | return 'workflow'; 69 | } 70 | 71 | public function reset(): void 72 | { 73 | $this->data = []; 74 | } 75 | 76 | public function getWorkflows(): array 77 | { 78 | return $this->data['workflows'] ?? []; 79 | } 80 | 81 | public function getCallsCount(): int 82 | { 83 | $i = 0; 84 | foreach ($this->getWorkflows() as $workflow) { 85 | $i += \count($workflow['calls']); 86 | } 87 | 88 | return $i; 89 | } 90 | 91 | public function hash(string $string): string 92 | { 93 | return hash('xxh128', $string); 94 | } 95 | 96 | public function buildMermaidLiveLink(string $name): string 97 | { 98 | $payload = [ 99 | 'code' => $this->data['workflows'][$name]['dump'], 100 | 'mermaid' => '{"theme": "default"}', 101 | 'autoSync' => false, 102 | ]; 103 | 104 | $compressed = zlib_encode(json_encode($payload), \ZLIB_ENCODING_DEFLATE); 105 | 106 | $suffix = rtrim(strtr(base64_encode($compressed), '+/', '-_'), '='); 107 | 108 | return "https://mermaid.live/edit#pako:{$suffix}"; 109 | } 110 | 111 | protected function getCasters(): array 112 | { 113 | return [ 114 | ...parent::getCasters(), 115 | TransitionBlocker::class => static function ($v, array $a, Stub $s) { 116 | unset($a[\sprintf(Caster::PATTERN_PRIVATE, $v::class, 'code')]); 117 | unset($a[\sprintf(Caster::PATTERN_PRIVATE, $v::class, 'parameters')]); 118 | 119 | $s->cut += 2; 120 | 121 | return $a; 122 | }, 123 | Marking::class => static function ($v, array $a) { 124 | $a[Caster::PREFIX_VIRTUAL.'.places'] = array_keys($v->getPlaces()); 125 | 126 | return $a; 127 | }, 128 | ]; 129 | } 130 | 131 | private function getEventListeners(WorkflowInterface $workflow): array 132 | { 133 | $listeners = []; 134 | $placeId = 0; 135 | foreach ($workflow->getDefinition()->getPlaces() as $place) { 136 | $eventNames = []; 137 | $subEventNames = [ 138 | 'leave', 139 | 'enter', 140 | 'entered', 141 | ]; 142 | foreach ($subEventNames as $subEventName) { 143 | $eventNames[] = \sprintf('workflow.%s', $subEventName); 144 | $eventNames[] = \sprintf('workflow.%s.%s', $workflow->getName(), $subEventName); 145 | $eventNames[] = \sprintf('workflow.%s.%s.%s', $workflow->getName(), $subEventName, $place); 146 | } 147 | foreach ($eventNames as $eventName) { 148 | foreach ($this->eventDispatcher->getListeners($eventName) as $listener) { 149 | $listeners["place{$placeId}"][$eventName][] = $this->summarizeListener($listener); 150 | } 151 | } 152 | 153 | ++$placeId; 154 | } 155 | 156 | foreach ($workflow->getDefinition()->getTransitions() as $transitionId => $transition) { 157 | $eventNames = []; 158 | $subEventNames = [ 159 | 'guard', 160 | 'transition', 161 | 'completed', 162 | 'announce', 163 | ]; 164 | foreach ($subEventNames as $subEventName) { 165 | $eventNames[] = \sprintf('workflow.%s', $subEventName); 166 | $eventNames[] = \sprintf('workflow.%s.%s', $workflow->getName(), $subEventName); 167 | $eventNames[] = \sprintf('workflow.%s.%s.%s', $workflow->getName(), $subEventName, $transition->getName()); 168 | } 169 | foreach ($eventNames as $eventName) { 170 | foreach ($this->eventDispatcher->getListeners($eventName) as $listener) { 171 | $listeners["transition{$transitionId}"][$eventName][] = $this->summarizeListener($listener, $eventName, $transition); 172 | } 173 | } 174 | } 175 | 176 | return $listeners; 177 | } 178 | 179 | private function summarizeListener(callable $callable, ?string $eventName = null, ?Transition $transition = null): array 180 | { 181 | $extra = []; 182 | 183 | if ($callable instanceof \Closure) { 184 | $r = new \ReflectionFunction($callable); 185 | if ($r->isAnonymous()) { 186 | $title = (string) $r; 187 | } elseif ($class = $r->getClosureCalledClass()) { 188 | $title = $class->name.'::'.$r->name.'()'; 189 | } else { 190 | $title = $r->name; 191 | } 192 | } elseif (\is_string($callable)) { 193 | $title = $callable.'()'; 194 | $r = new \ReflectionFunction($callable); 195 | } elseif (\is_object($callable) && method_exists($callable, '__invoke')) { 196 | $r = new \ReflectionMethod($callable, '__invoke'); 197 | $title = $callable::class.'::__invoke()'; 198 | } elseif (\is_array($callable)) { 199 | if ($callable[0] instanceof GuardListener) { 200 | if (null === $eventName || null === $transition) { 201 | throw new \LogicException('Missing event name or transition.'); 202 | } 203 | $extra['guardExpressions'] = $this->extractGuardExpressions($callable[0], $eventName, $transition); 204 | } 205 | $r = new \ReflectionMethod($callable[0], $callable[1]); 206 | $title = (\is_string($callable[0]) ? $callable[0] : \get_class($callable[0])).'::'.$callable[1].'()'; 207 | } else { 208 | throw new \RuntimeException('Unknown callable type.'); 209 | } 210 | 211 | $file = null; 212 | if ($r->isUserDefined()) { 213 | $file = $this->fileLinkFormatter->format($r->getFileName(), $r->getStartLine()); 214 | } 215 | 216 | return [ 217 | 'title' => $title, 218 | 'file' => $file, 219 | ...$extra, 220 | ]; 221 | } 222 | 223 | private function extractGuardExpressions(GuardListener $listener, string $eventName, Transition $transition): array 224 | { 225 | $configuration = (new \ReflectionProperty(GuardListener::class, 'configuration'))->getValue($listener); 226 | 227 | $expressions = []; 228 | foreach ($configuration[$eventName] as $guard) { 229 | if ($guard instanceof GuardExpression) { 230 | if ($guard->getTransition() !== $transition) { 231 | continue; 232 | } 233 | $expressions[] = $guard->getExpression(); 234 | } else { 235 | $expressions[] = $guard; 236 | } 237 | } 238 | 239 | return $expressions; 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /Dumper/PlantUmlDumper.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Dumper; 13 | 14 | use Symfony\Component\Workflow\Definition; 15 | use Symfony\Component\Workflow\Marking; 16 | use Symfony\Component\Workflow\Metadata\MetadataStoreInterface; 17 | use Symfony\Component\Workflow\Transition; 18 | 19 | /** 20 | * PlantUmlDumper dumps a workflow as a PlantUML file. 21 | * 22 | * You can convert the generated puml file with the plantuml.jar utility (http://plantuml.com/): 23 | * 24 | * php bin/console workflow:dump pull_request travis --dump-format=puml | java -jar plantuml.jar -p > workflow.png 25 | * 26 | * @author Sébastien Morel 27 | */ 28 | class PlantUmlDumper implements DumperInterface 29 | { 30 | private const INITIAL = '<>'; 31 | private const MARKED = '<>'; 32 | 33 | public const STATEMACHINE_TRANSITION = 'arrow'; 34 | public const WORKFLOW_TRANSITION = 'square'; 35 | public const TRANSITION_TYPES = [self::STATEMACHINE_TRANSITION, self::WORKFLOW_TRANSITION]; 36 | public const DEFAULT_OPTIONS = [ 37 | 'skinparams' => [ 38 | 'titleBorderRoundCorner' => 15, 39 | 'titleBorderThickness' => 2, 40 | 'state' => [ 41 | 'BackgroundColor'.self::INITIAL => '#87b741', 42 | 'BackgroundColor'.self::MARKED => '#3887C6', 43 | 'BorderColor' => '#3887C6', 44 | 'BorderColor'.self::MARKED => 'Black', 45 | 'FontColor'.self::MARKED => 'White', 46 | ], 47 | 'agent' => [ 48 | 'BackgroundColor' => '#ffffff', 49 | 'BorderColor' => '#3887C6', 50 | ], 51 | ], 52 | ]; 53 | 54 | public function __construct( 55 | private string $transitionType, 56 | ) { 57 | if (!\in_array($transitionType, self::TRANSITION_TYPES, true)) { 58 | throw new \InvalidArgumentException("Transition type '$transitionType' does not exist."); 59 | } 60 | } 61 | 62 | public function dump(Definition $definition, ?Marking $marking = null, array $options = []): string 63 | { 64 | $options = array_replace_recursive(self::DEFAULT_OPTIONS, $options); 65 | 66 | $workflowMetadata = $definition->getMetadataStore(); 67 | 68 | $code = $this->initialize($options, $definition); 69 | 70 | foreach ($definition->getPlaces() as $place) { 71 | $code[] = $this->getState($place, $definition, $marking); 72 | } 73 | if ($this->isWorkflowTransitionType()) { 74 | foreach ($definition->getTransitions() as $transition) { 75 | $transitionEscaped = $this->escape($transition->getName()); 76 | $code[] = "agent $transitionEscaped"; 77 | } 78 | } 79 | foreach ($definition->getTransitions() as $transition) { 80 | $transitionEscaped = $this->escape($transition->getName()); 81 | foreach ($transition->getFroms() as $from) { 82 | $fromEscaped = $this->escape($from); 83 | foreach ($transition->getTos() as $to) { 84 | $toEscaped = $this->escape($to); 85 | 86 | $transitionEscapedWithStyle = $this->getTransitionEscapedWithStyle($workflowMetadata, $transition, $transitionEscaped); 87 | 88 | $arrowColor = $workflowMetadata->getMetadata('arrow_color', $transition); 89 | 90 | $transitionColor = ''; 91 | if (null !== $arrowColor) { 92 | $transitionColor = $this->getTransitionColor($arrowColor) ?? ''; 93 | } 94 | 95 | if ($this->isWorkflowTransitionType()) { 96 | $transitionLabel = ''; 97 | // Add label only if it has a style 98 | if ($transitionEscapedWithStyle != $transitionEscaped) { 99 | $transitionLabel = ": $transitionEscapedWithStyle"; 100 | } 101 | 102 | $lines = [ 103 | "{$fromEscaped} -{$transitionColor}-> {$transitionEscaped}{$transitionLabel}", 104 | "{$transitionEscaped} -{$transitionColor}-> {$toEscaped}{$transitionLabel}", 105 | ]; 106 | foreach ($lines as $line) { 107 | if (!\in_array($line, $code)) { 108 | $code[] = $line; 109 | } 110 | } 111 | } else { 112 | $code[] = "{$fromEscaped} -{$transitionColor}-> {$toEscaped}: {$transitionEscapedWithStyle}"; 113 | } 114 | } 115 | } 116 | } 117 | 118 | return $this->startPuml().$this->getLines($code).$this->endPuml(); 119 | } 120 | 121 | private function isWorkflowTransitionType(): bool 122 | { 123 | return self::WORKFLOW_TRANSITION === $this->transitionType; 124 | } 125 | 126 | private function startPuml(): string 127 | { 128 | return '@startuml'.\PHP_EOL.'allow_mixing'.\PHP_EOL; 129 | } 130 | 131 | private function endPuml(): string 132 | { 133 | return \PHP_EOL.'@enduml'; 134 | } 135 | 136 | private function getLines(array $code): string 137 | { 138 | return implode(\PHP_EOL, $code); 139 | } 140 | 141 | private function initialize(array $options, Definition $definition): array 142 | { 143 | $workflowMetadata = $definition->getMetadataStore(); 144 | 145 | $code = []; 146 | if (isset($options['title'])) { 147 | $code[] = "title {$options['title']}"; 148 | } 149 | if (isset($options['name'])) { 150 | $code[] = "title {$options['name']}"; 151 | } 152 | 153 | // Add style from nodes 154 | foreach ($definition->getPlaces() as $place) { 155 | $backgroundColor = $workflowMetadata->getMetadata('bg_color', $place); 156 | if (null !== $backgroundColor) { 157 | $key = 'BackgroundColor<<'.$this->getColorId($backgroundColor).'>>'; 158 | 159 | $options['skinparams']['state'][$key] = $backgroundColor; 160 | } 161 | } 162 | 163 | if (isset($options['skinparams']) && \is_array($options['skinparams'])) { 164 | foreach ($options['skinparams'] as $skinparamKey => $skinparamValue) { 165 | if (!$this->isWorkflowTransitionType() && 'agent' === $skinparamKey) { 166 | continue; 167 | } 168 | if (!\is_array($skinparamValue)) { 169 | $code[] = "skinparam {$skinparamKey} $skinparamValue"; 170 | continue; 171 | } 172 | $code[] = "skinparam {$skinparamKey} {"; 173 | foreach ($skinparamValue as $key => $value) { 174 | $code[] = " {$key} $value"; 175 | } 176 | $code[] = '}'; 177 | } 178 | } 179 | 180 | return $code; 181 | } 182 | 183 | private function escape(string $string): string 184 | { 185 | // It's not possible to escape property double quote, so let's remove it 186 | return '"'.str_replace('"', '', $string).'"'; 187 | } 188 | 189 | private function getState(string $place, Definition $definition, ?Marking $marking = null): string 190 | { 191 | $workflowMetadata = $definition->getMetadataStore(); 192 | 193 | $placeEscaped = str_replace("\n", ' ', $this->escape($place)); 194 | 195 | $output = "state $placeEscaped". 196 | (\in_array($place, $definition->getInitialPlaces(), true) ? ' '.self::INITIAL : ''). 197 | ($marking?->has($place) ? ' '.self::MARKED : ''); 198 | 199 | $backgroundColor = $workflowMetadata->getMetadata('bg_color', $place); 200 | if (null !== $backgroundColor) { 201 | $output .= ' <<'.$this->getColorId($backgroundColor).'>>'; 202 | } 203 | 204 | $description = $workflowMetadata->getMetadata('description', $place); 205 | if (null !== $description) { 206 | foreach (array_filter(explode("\n", $description)) as $line) { 207 | $output .= "\n".$placeEscaped.' : '.$line; 208 | } 209 | } 210 | 211 | return $output; 212 | } 213 | 214 | private function getTransitionEscapedWithStyle(MetadataStoreInterface $workflowMetadata, Transition $transition, string $to): string 215 | { 216 | $to = $workflowMetadata->getMetadata('label', $transition) ?? $to; 217 | // Change new lines symbols to actual '\n' string, 218 | // PUML will render them as new lines 219 | $to = str_replace("\n", '\n', $to); 220 | 221 | $color = $workflowMetadata->getMetadata('color', $transition) ?? null; 222 | 223 | if (null !== $color) { 224 | // Close and open before and after every '\n' string, 225 | // so that the style is applied properly on every line 226 | $to = str_replace('\n', \sprintf('\n', $color), $to); 227 | 228 | $to = \sprintf( 229 | '%2$s', 230 | $color, 231 | $to 232 | ); 233 | } 234 | 235 | return $this->escape($to); 236 | } 237 | 238 | private function getTransitionColor(string $color): string 239 | { 240 | // PUML format requires that color in transition have to be prefixed with “#”. 241 | if (!str_starts_with($color, '#')) { 242 | $color = '#'.$color; 243 | } 244 | 245 | return \sprintf('[%s]', $color); 246 | } 247 | 248 | private function getColorId(string $color): string 249 | { 250 | // Remove “#“ from start of the color name so it can be used as an identifier. 251 | return ltrim($color, '#'); 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /Dumper/GraphvizDumper.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow\Dumper; 13 | 14 | use Symfony\Component\Workflow\Definition; 15 | use Symfony\Component\Workflow\Marking; 16 | 17 | /** 18 | * GraphvizDumper dumps a workflow as a graphviz file. 19 | * 20 | * You can convert the generated dot file with the dot utility (https://graphviz.org/): 21 | * 22 | * dot -Tpng workflow.dot > workflow.png 23 | * 24 | * @author Fabien Potencier 25 | * @author Grégoire Pineau 26 | */ 27 | class GraphvizDumper implements DumperInterface 28 | { 29 | // All values should be strings 30 | protected static array $defaultOptions = [ 31 | 'graph' => ['ratio' => 'compress', 'rankdir' => 'LR'], 32 | 'node' => ['fontsize' => '9', 'fontname' => 'Arial', 'color' => '#333333', 'fillcolor' => 'lightblue', 'fixedsize' => 'false', 'width' => '1'], 33 | 'edge' => ['fontsize' => '9', 'fontname' => 'Arial', 'color' => '#333333', 'arrowhead' => 'normal', 'arrowsize' => '0.5'], 34 | ]; 35 | 36 | /** 37 | * Dumps the workflow as a graphviz graph. 38 | * 39 | * Available options: 40 | * 41 | * * graph: The default options for the whole graph 42 | * * node: The default options for nodes (places + transitions) 43 | * * edge: The default options for edges 44 | */ 45 | public function dump(Definition $definition, ?Marking $marking = null, array $options = []): string 46 | { 47 | $withMetadata = $options['with-metadata'] ?? false; 48 | 49 | $places = $this->findPlaces($definition, $withMetadata, $marking); 50 | $transitions = $this->findTransitions($definition, $withMetadata); 51 | $edges = $this->findEdges($definition); 52 | 53 | $options = array_replace_recursive(self::$defaultOptions, $options); 54 | 55 | $label = $this->formatLabel($definition, $withMetadata, $options); 56 | 57 | return $this->startDot($options, $label) 58 | .$this->addPlaces($places, $withMetadata) 59 | .$this->addTransitions($transitions, $withMetadata) 60 | .$this->addEdges($edges) 61 | .$this->endDot(); 62 | } 63 | 64 | /** 65 | * @internal 66 | */ 67 | protected function findPlaces(Definition $definition, bool $withMetadata, ?Marking $marking = null): array 68 | { 69 | $workflowMetadata = $definition->getMetadataStore(); 70 | 71 | $places = []; 72 | 73 | foreach ($definition->getPlaces() as $place) { 74 | $attributes = []; 75 | if (\in_array($place, $definition->getInitialPlaces(), true)) { 76 | $attributes['style'] = 'filled'; 77 | } 78 | if ($marking?->has($place)) { 79 | $attributes['color'] = '#FF0000'; 80 | $attributes['shape'] = 'doublecircle'; 81 | } 82 | $backgroundColor = $workflowMetadata->getMetadata('bg_color', $place); 83 | if (null !== $backgroundColor) { 84 | $attributes['style'] = 'filled'; 85 | $attributes['fillcolor'] = $backgroundColor; 86 | } 87 | if ($withMetadata) { 88 | $attributes['metadata'] = $workflowMetadata->getPlaceMetadata($place); 89 | } 90 | $label = $workflowMetadata->getMetadata('label', $place); 91 | if (null !== $label) { 92 | $attributes['name'] = $label; 93 | if ($withMetadata) { 94 | // Don't include label in metadata if already used as name 95 | unset($attributes['metadata']['label']); 96 | } 97 | } 98 | $places[$place] = [ 99 | 'attributes' => $attributes, 100 | ]; 101 | } 102 | 103 | return $places; 104 | } 105 | 106 | /** 107 | * @internal 108 | */ 109 | protected function findTransitions(Definition $definition, bool $withMetadata): array 110 | { 111 | $workflowMetadata = $definition->getMetadataStore(); 112 | 113 | $transitions = []; 114 | 115 | foreach ($definition->getTransitions() as $transition) { 116 | $attributes = ['shape' => 'box', 'regular' => true]; 117 | 118 | $backgroundColor = $workflowMetadata->getMetadata('bg_color', $transition); 119 | if (null !== $backgroundColor) { 120 | $attributes['style'] = 'filled'; 121 | $attributes['fillcolor'] = $backgroundColor; 122 | } 123 | $name = $workflowMetadata->getMetadata('label', $transition) ?? $transition->getName(); 124 | 125 | $metadata = []; 126 | if ($withMetadata) { 127 | $metadata = $workflowMetadata->getTransitionMetadata($transition); 128 | unset($metadata['label']); 129 | } 130 | 131 | $transitions[] = [ 132 | 'attributes' => $attributes, 133 | 'name' => $name, 134 | 'metadata' => $metadata, 135 | ]; 136 | } 137 | 138 | return $transitions; 139 | } 140 | 141 | /** 142 | * @internal 143 | */ 144 | protected function addPlaces(array $places, bool $withMetadata): string 145 | { 146 | $code = ''; 147 | 148 | foreach ($places as $id => $place) { 149 | if (isset($place['attributes']['name'])) { 150 | $placeName = $place['attributes']['name']; 151 | unset($place['attributes']['name']); 152 | } else { 153 | $placeName = $id; 154 | } 155 | 156 | if ($withMetadata) { 157 | $escapedLabel = \sprintf('<%s%s>', $this->escape($placeName), $this->addMetadata($place['attributes']['metadata'])); 158 | // Don't include metadata in default attributes used to format the place 159 | unset($place['attributes']['metadata']); 160 | } else { 161 | $escapedLabel = \sprintf('"%s"', $this->escape($placeName)); 162 | } 163 | 164 | $code .= \sprintf(" place_%s [label=%s, shape=circle%s];\n", $this->dotize($id), $escapedLabel, $this->addAttributes($place['attributes'])); 165 | } 166 | 167 | return $code; 168 | } 169 | 170 | /** 171 | * @internal 172 | */ 173 | protected function addTransitions(array $transitions, bool $withMetadata): string 174 | { 175 | $code = ''; 176 | 177 | foreach ($transitions as $i => $place) { 178 | if ($withMetadata) { 179 | $escapedLabel = \sprintf('<%s%s>', $this->escape($place['name']), $this->addMetadata($place['metadata'])); 180 | } else { 181 | $escapedLabel = '"'.$this->escape($place['name']).'"'; 182 | } 183 | 184 | $code .= \sprintf(" transition_%s [label=%s,%s];\n", $this->dotize($i), $escapedLabel, $this->addAttributes($place['attributes'])); 185 | } 186 | 187 | return $code; 188 | } 189 | 190 | /** 191 | * @internal 192 | */ 193 | protected function findEdges(Definition $definition): array 194 | { 195 | $workflowMetadata = $definition->getMetadataStore(); 196 | 197 | $dotEdges = []; 198 | 199 | foreach ($definition->getTransitions() as $i => $transition) { 200 | $transitionName = $workflowMetadata->getMetadata('label', $transition) ?? $transition->getName(); 201 | 202 | foreach ($transition->getFroms() as $from) { 203 | $dotEdges[] = [ 204 | 'from' => $from, 205 | 'to' => $transitionName, 206 | 'direction' => 'from', 207 | 'transition_number' => $i, 208 | ]; 209 | } 210 | foreach ($transition->getTos() as $to) { 211 | $dotEdges[] = [ 212 | 'from' => $transitionName, 213 | 'to' => $to, 214 | 'direction' => 'to', 215 | 'transition_number' => $i, 216 | ]; 217 | } 218 | } 219 | 220 | return $dotEdges; 221 | } 222 | 223 | /** 224 | * @internal 225 | */ 226 | protected function addEdges(array $edges): string 227 | { 228 | $code = ''; 229 | 230 | foreach ($edges as $edge) { 231 | if ('from' === $edge['direction']) { 232 | $code .= \sprintf(" place_%s -> transition_%s [style=\"solid\"];\n", 233 | $this->dotize($edge['from']), 234 | $this->dotize($edge['transition_number']) 235 | ); 236 | } else { 237 | $code .= \sprintf(" transition_%s -> place_%s [style=\"solid\"];\n", 238 | $this->dotize($edge['transition_number']), 239 | $this->dotize($edge['to']) 240 | ); 241 | } 242 | } 243 | 244 | return $code; 245 | } 246 | 247 | /** 248 | * @internal 249 | */ 250 | protected function startDot(array $options, string $label): string 251 | { 252 | return \sprintf("digraph workflow {\n %s%s\n node [%s];\n edge [%s];\n\n", 253 | $this->addOptions($options['graph']), 254 | '""' !== $label && '<>' !== $label ? \sprintf(' label=%s', $label) : '', 255 | $this->addOptions($options['node']), 256 | $this->addOptions($options['edge']) 257 | ); 258 | } 259 | 260 | /** 261 | * @internal 262 | */ 263 | protected function endDot(): string 264 | { 265 | return "}\n"; 266 | } 267 | 268 | /** 269 | * @internal 270 | */ 271 | protected function dotize(string $id): string 272 | { 273 | return hash('sha1', $id); 274 | } 275 | 276 | /** 277 | * @internal 278 | */ 279 | protected function escape(string|bool $value): string 280 | { 281 | return \is_bool($value) ? ($value ? '1' : '0') : addslashes($value); 282 | } 283 | 284 | /** 285 | * @internal 286 | */ 287 | protected function addAttributes(array $attributes): string 288 | { 289 | $code = []; 290 | 291 | foreach ($attributes as $k => $v) { 292 | $code[] = \sprintf('%s="%s"', $k, $this->escape($v)); 293 | } 294 | 295 | return $code ? ' '.implode(' ', $code) : ''; 296 | } 297 | 298 | /** 299 | * Handles the label of the graph depending on whether a label was set in CLI, 300 | * if metadata should be included and if there are any. 301 | * 302 | * The produced label must be escaped. 303 | * 304 | * @internal 305 | */ 306 | protected function formatLabel(Definition $definition, bool $withMetadata, array $options): string 307 | { 308 | $currentLabel = $options['label'] ?? ''; 309 | 310 | if (!$withMetadata) { 311 | // Only currentLabel to handle. If null, will be translated to empty string 312 | return \sprintf('"%s"', $this->escape($currentLabel)); 313 | } 314 | $workflowMetadata = $definition->getMetadataStore()->getWorkflowMetadata(); 315 | 316 | if ('' === $currentLabel) { 317 | // Only metadata to handle 318 | return \sprintf('<%s>', $this->addMetadata($workflowMetadata, false)); 319 | } 320 | 321 | // currentLabel and metadata to handle 322 | return \sprintf('<%s%s>', $this->escape($currentLabel), $this->addMetadata($workflowMetadata)); 323 | } 324 | 325 | private function addOptions(array $options): string 326 | { 327 | $code = []; 328 | 329 | foreach ($options as $k => $v) { 330 | $code[] = \sprintf('%s="%s"', $k, $v); 331 | } 332 | 333 | return implode(' ', $code); 334 | } 335 | 336 | /** 337 | * @param bool $lineBreakFirstIfNotEmpty Whether to add a separator in the first place when metadata is not empty 338 | */ 339 | private function addMetadata(array $metadata, bool $lineBreakFirstIfNotEmpty = true): string 340 | { 341 | $code = []; 342 | 343 | $skipSeparator = !$lineBreakFirstIfNotEmpty; 344 | 345 | foreach ($metadata as $key => $value) { 346 | if ($skipSeparator) { 347 | $code[] = \sprintf('%s: %s', $this->escape($key), $this->escape($value)); 348 | $skipSeparator = false; 349 | } else { 350 | $code[] = \sprintf('%s%s: %s', '
', $this->escape($key), $this->escape($value)); 351 | } 352 | } 353 | 354 | return $code ? implode('', $code) : ''; 355 | } 356 | } 357 | -------------------------------------------------------------------------------- /Workflow.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Workflow; 13 | 14 | use Symfony\Component\Workflow\Event\AnnounceEvent; 15 | use Symfony\Component\Workflow\Event\CompletedEvent; 16 | use Symfony\Component\Workflow\Event\EnteredEvent; 17 | use Symfony\Component\Workflow\Event\EnterEvent; 18 | use Symfony\Component\Workflow\Event\GuardEvent; 19 | use Symfony\Component\Workflow\Event\LeaveEvent; 20 | use Symfony\Component\Workflow\Event\TransitionEvent; 21 | use Symfony\Component\Workflow\Exception\LogicException; 22 | use Symfony\Component\Workflow\Exception\NotEnabledTransitionException; 23 | use Symfony\Component\Workflow\Exception\UndefinedTransitionException; 24 | use Symfony\Component\Workflow\MarkingStore\MarkingStoreInterface; 25 | use Symfony\Component\Workflow\MarkingStore\MethodMarkingStore; 26 | use Symfony\Component\Workflow\Metadata\MetadataStoreInterface; 27 | use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; 28 | 29 | /** 30 | * @author Fabien Potencier 31 | * @author Grégoire Pineau 32 | * @author Tobias Nyholm 33 | * @author Carlos Pereira De Amorim 34 | */ 35 | class Workflow implements WorkflowInterface 36 | { 37 | public const DISABLE_LEAVE_EVENT = 'workflow_disable_leave_event'; 38 | public const DISABLE_TRANSITION_EVENT = 'workflow_disable_transition_event'; 39 | public const DISABLE_ENTER_EVENT = 'workflow_disable_enter_event'; 40 | public const DISABLE_ENTERED_EVENT = 'workflow_disable_entered_event'; 41 | public const DISABLE_COMPLETED_EVENT = 'workflow_disable_completed_event'; 42 | public const DISABLE_ANNOUNCE_EVENT = 'workflow_disable_announce_event'; 43 | 44 | public const DEFAULT_INITIAL_CONTEXT = ['initial' => true]; 45 | 46 | private const DISABLE_EVENTS_MAPPING = [ 47 | WorkflowEvents::LEAVE => self::DISABLE_LEAVE_EVENT, 48 | WorkflowEvents::TRANSITION => self::DISABLE_TRANSITION_EVENT, 49 | WorkflowEvents::ENTER => self::DISABLE_ENTER_EVENT, 50 | WorkflowEvents::ENTERED => self::DISABLE_ENTERED_EVENT, 51 | WorkflowEvents::COMPLETED => self::DISABLE_COMPLETED_EVENT, 52 | WorkflowEvents::ANNOUNCE => self::DISABLE_ANNOUNCE_EVENT, 53 | ]; 54 | 55 | private MarkingStoreInterface $markingStore; 56 | 57 | /** 58 | * @param array|string[]|null $eventsToDispatch When `null` fire all events (the default behaviour). 59 | * Setting this to an empty array `[]` means no events are dispatched (except the {@see GuardEvent}). 60 | * Passing an array with WorkflowEvents will allow only those events to be dispatched plus 61 | * the {@see GuardEvent}. 62 | */ 63 | public function __construct( 64 | private Definition $definition, 65 | ?MarkingStoreInterface $markingStore = null, 66 | private ?EventDispatcherInterface $dispatcher = null, 67 | private string $name = 'unnamed', 68 | private ?array $eventsToDispatch = null, 69 | ) { 70 | $this->markingStore = $markingStore ?? new MethodMarkingStore(); 71 | } 72 | 73 | public function getMarking(object $subject, array $context = []): Marking 74 | { 75 | $marking = $this->markingStore->getMarking($subject); 76 | 77 | // check if the subject is already in the workflow 78 | if (!$marking->getPlaces()) { 79 | if (!$this->definition->getInitialPlaces()) { 80 | throw new LogicException(\sprintf('The Marking is empty and there is no initial place for workflow "%s".', $this->name)); 81 | } 82 | foreach ($this->definition->getInitialPlaces() as $place) { 83 | $marking->mark($place); 84 | } 85 | 86 | // update the subject with the new marking 87 | $this->markingStore->setMarking($subject, $marking); 88 | 89 | if (!$context) { 90 | $context = self::DEFAULT_INITIAL_CONTEXT; 91 | } 92 | 93 | $this->entered($subject, null, $marking, $context); 94 | } 95 | 96 | // check that the subject has a known place 97 | $places = $this->definition->getPlaces(); 98 | foreach ($marking->getPlaces() as $placeName => $nbToken) { 99 | if (!isset($places[$placeName])) { 100 | $message = \sprintf('Place "%s" is not valid for workflow "%s".', $placeName, $this->name); 101 | if (!$places) { 102 | $message .= ' It seems you forgot to add places to the current workflow.'; 103 | } 104 | 105 | throw new LogicException($message); 106 | } 107 | } 108 | 109 | return $marking; 110 | } 111 | 112 | public function can(object $subject, string $transitionName): bool 113 | { 114 | $transitions = $this->definition->getTransitions(); 115 | $marking = $this->getMarking($subject); 116 | 117 | foreach ($transitions as $transition) { 118 | if ($transition->getName() !== $transitionName) { 119 | continue; 120 | } 121 | 122 | $transitionBlockerList = $this->buildTransitionBlockerListForTransition($subject, $marking, $transition); 123 | 124 | if ($transitionBlockerList->isEmpty()) { 125 | return true; 126 | } 127 | } 128 | 129 | return false; 130 | } 131 | 132 | public function buildTransitionBlockerList(object $subject, string $transitionName): TransitionBlockerList 133 | { 134 | $transitions = $this->definition->getTransitions(); 135 | $marking = $this->getMarking($subject); 136 | $transitionBlockerList = null; 137 | 138 | foreach ($transitions as $transition) { 139 | if ($transition->getName() !== $transitionName) { 140 | continue; 141 | } 142 | 143 | $transitionBlockerList = $this->buildTransitionBlockerListForTransition($subject, $marking, $transition); 144 | 145 | if ($transitionBlockerList->isEmpty()) { 146 | return $transitionBlockerList; 147 | } 148 | 149 | // We prefer to return transitions blocker by something else than 150 | // marking. Because it means the marking was OK. Transitions are 151 | // deterministic: it's not possible to have many transitions enabled 152 | // at the same time that match the same marking with the same name 153 | if (!$transitionBlockerList->has(TransitionBlocker::BLOCKED_BY_MARKING)) { 154 | return $transitionBlockerList; 155 | } 156 | } 157 | 158 | if (!$transitionBlockerList) { 159 | throw new UndefinedTransitionException($subject, $transitionName, $this); 160 | } 161 | 162 | return $transitionBlockerList; 163 | } 164 | 165 | public function apply(object $subject, string $transitionName, array $context = []): Marking 166 | { 167 | $marking = $this->getMarking($subject, $context); 168 | 169 | $transitionExist = false; 170 | $approvedTransitions = []; 171 | $bestTransitionBlockerList = null; 172 | 173 | foreach ($this->definition->getTransitions() as $transition) { 174 | if ($transition->getName() !== $transitionName) { 175 | continue; 176 | } 177 | 178 | $transitionExist = true; 179 | 180 | $tmpTransitionBlockerList = $this->buildTransitionBlockerListForTransition($subject, $marking, $transition); 181 | 182 | if ($tmpTransitionBlockerList->isEmpty()) { 183 | $approvedTransitions[] = $transition; 184 | continue; 185 | } 186 | 187 | if (!$bestTransitionBlockerList) { 188 | $bestTransitionBlockerList = $tmpTransitionBlockerList; 189 | continue; 190 | } 191 | 192 | // We prefer to return transitions blocker by something else than 193 | // marking. Because it means the marking was OK. Transitions are 194 | // deterministic: it's not possible to have many transitions enabled 195 | // at the same time that match the same marking with the same name 196 | if (!$tmpTransitionBlockerList->has(TransitionBlocker::BLOCKED_BY_MARKING)) { 197 | $bestTransitionBlockerList = $tmpTransitionBlockerList; 198 | } 199 | } 200 | 201 | if (!$transitionExist) { 202 | throw new UndefinedTransitionException($subject, $transitionName, $this, $context); 203 | } 204 | 205 | if (!$approvedTransitions) { 206 | throw new NotEnabledTransitionException($subject, $transitionName, $this, $bestTransitionBlockerList, $context); 207 | } 208 | 209 | foreach ($approvedTransitions as $transition) { 210 | $this->leave($subject, $transition, $marking, $context); 211 | 212 | $context = $this->transition($subject, $transition, $marking, $context); 213 | 214 | $this->enter($subject, $transition, $marking, $context); 215 | 216 | $this->markingStore->setMarking($subject, $marking, $context); 217 | 218 | $this->entered($subject, $transition, $marking, $context); 219 | 220 | $this->completed($subject, $transition, $marking, $context); 221 | 222 | $this->announce($subject, $transition, $marking, $context); 223 | } 224 | 225 | $marking->setContext($context); 226 | 227 | return $marking; 228 | } 229 | 230 | public function getEnabledTransitions(object $subject): array 231 | { 232 | $enabledTransitions = []; 233 | $marking = $this->getMarking($subject); 234 | 235 | foreach ($this->definition->getTransitions() as $transition) { 236 | $transitionBlockerList = $this->buildTransitionBlockerListForTransition($subject, $marking, $transition); 237 | if ($transitionBlockerList->isEmpty()) { 238 | $enabledTransitions[] = $transition; 239 | } 240 | } 241 | 242 | return $enabledTransitions; 243 | } 244 | 245 | public function getEnabledTransition(object $subject, string $name): ?Transition 246 | { 247 | $marking = $this->getMarking($subject); 248 | 249 | foreach ($this->definition->getTransitions() as $transition) { 250 | if ($transition->getName() !== $name) { 251 | continue; 252 | } 253 | $transitionBlockerList = $this->buildTransitionBlockerListForTransition($subject, $marking, $transition); 254 | if (!$transitionBlockerList->isEmpty()) { 255 | continue; 256 | } 257 | 258 | return $transition; 259 | } 260 | 261 | return null; 262 | } 263 | 264 | public function getName(): string 265 | { 266 | return $this->name; 267 | } 268 | 269 | public function getDefinition(): Definition 270 | { 271 | return $this->definition; 272 | } 273 | 274 | public function getMarkingStore(): MarkingStoreInterface 275 | { 276 | return $this->markingStore; 277 | } 278 | 279 | public function getMetadataStore(): MetadataStoreInterface 280 | { 281 | return $this->definition->getMetadataStore(); 282 | } 283 | 284 | private function buildTransitionBlockerListForTransition(object $subject, Marking $marking, Transition $transition): TransitionBlockerList 285 | { 286 | foreach ($transition->getFroms() as $place) { 287 | if (!$marking->has($place)) { 288 | return new TransitionBlockerList([ 289 | TransitionBlocker::createBlockedByMarking($marking), 290 | ]); 291 | } 292 | } 293 | 294 | if (null === $this->dispatcher) { 295 | return new TransitionBlockerList(); 296 | } 297 | 298 | $event = $this->guardTransition($subject, $marking, $transition); 299 | 300 | if ($event->isBlocked()) { 301 | return $event->getTransitionBlockerList(); 302 | } 303 | 304 | return new TransitionBlockerList(); 305 | } 306 | 307 | private function guardTransition(object $subject, Marking $marking, Transition $transition): ?GuardEvent 308 | { 309 | if (null === $this->dispatcher) { 310 | return null; 311 | } 312 | 313 | $event = new GuardEvent($subject, $marking, $transition, $this); 314 | 315 | $this->dispatcher->dispatch($event, WorkflowEvents::GUARD); 316 | $this->dispatcher->dispatch($event, \sprintf('workflow.%s.guard', $this->name)); 317 | $this->dispatcher->dispatch($event, \sprintf('workflow.%s.guard.%s', $this->name, $transition->getName())); 318 | 319 | return $event; 320 | } 321 | 322 | private function leave(object $subject, Transition $transition, Marking $marking, array $context = []): void 323 | { 324 | $places = $transition->getFroms(); 325 | 326 | if ($this->shouldDispatchEvent(WorkflowEvents::LEAVE, $context)) { 327 | $event = new LeaveEvent($subject, $marking, $transition, $this, $context); 328 | 329 | $this->dispatcher->dispatch($event, WorkflowEvents::LEAVE); 330 | $this->dispatcher->dispatch($event, \sprintf('workflow.%s.leave', $this->name)); 331 | 332 | foreach ($places as $place) { 333 | $this->dispatcher->dispatch($event, \sprintf('workflow.%s.leave.%s', $this->name, $place)); 334 | } 335 | } 336 | 337 | foreach ($places as $place) { 338 | $marking->unmark($place); 339 | } 340 | } 341 | 342 | private function transition(object $subject, Transition $transition, Marking $marking, array $context): array 343 | { 344 | if (!$this->shouldDispatchEvent(WorkflowEvents::TRANSITION, $context)) { 345 | return $context; 346 | } 347 | 348 | $event = new TransitionEvent($subject, $marking, $transition, $this, $context); 349 | 350 | $this->dispatcher->dispatch($event, WorkflowEvents::TRANSITION); 351 | $this->dispatcher->dispatch($event, \sprintf('workflow.%s.transition', $this->name)); 352 | $this->dispatcher->dispatch($event, \sprintf('workflow.%s.transition.%s', $this->name, $transition->getName())); 353 | 354 | return $event->getContext(); 355 | } 356 | 357 | private function enter(object $subject, Transition $transition, Marking $marking, array $context): void 358 | { 359 | $places = $transition->getTos(); 360 | 361 | if ($this->shouldDispatchEvent(WorkflowEvents::ENTER, $context)) { 362 | $event = new EnterEvent($subject, $marking, $transition, $this, $context); 363 | 364 | $this->dispatcher->dispatch($event, WorkflowEvents::ENTER); 365 | $this->dispatcher->dispatch($event, \sprintf('workflow.%s.enter', $this->name)); 366 | 367 | foreach ($places as $place) { 368 | $this->dispatcher->dispatch($event, \sprintf('workflow.%s.enter.%s', $this->name, $place)); 369 | } 370 | } 371 | 372 | foreach ($places as $place) { 373 | $marking->mark($place); 374 | } 375 | } 376 | 377 | private function entered(object $subject, ?Transition $transition, Marking $marking, array $context): void 378 | { 379 | if (!$this->shouldDispatchEvent(WorkflowEvents::ENTERED, $context)) { 380 | return; 381 | } 382 | 383 | $event = new EnteredEvent($subject, $marking, $transition, $this, $context); 384 | 385 | $this->dispatcher->dispatch($event, WorkflowEvents::ENTERED); 386 | $this->dispatcher->dispatch($event, \sprintf('workflow.%s.entered', $this->name)); 387 | 388 | $placeNames = []; 389 | if ($transition) { 390 | $placeNames = $transition->getTos(); 391 | } elseif ($this->definition->getInitialPlaces()) { 392 | $placeNames = $this->definition->getInitialPlaces(); 393 | } 394 | foreach ($placeNames as $placeName) { 395 | $this->dispatcher->dispatch($event, \sprintf('workflow.%s.entered.%s', $this->name, $placeName)); 396 | } 397 | } 398 | 399 | private function completed(object $subject, Transition $transition, Marking $marking, array $context): void 400 | { 401 | if (!$this->shouldDispatchEvent(WorkflowEvents::COMPLETED, $context)) { 402 | return; 403 | } 404 | 405 | $event = new CompletedEvent($subject, $marking, $transition, $this, $context); 406 | 407 | $this->dispatcher->dispatch($event, WorkflowEvents::COMPLETED); 408 | $this->dispatcher->dispatch($event, \sprintf('workflow.%s.completed', $this->name)); 409 | $this->dispatcher->dispatch($event, \sprintf('workflow.%s.completed.%s', $this->name, $transition->getName())); 410 | } 411 | 412 | private function announce(object $subject, Transition $initialTransition, Marking $marking, array $context): void 413 | { 414 | if (!$this->shouldDispatchEvent(WorkflowEvents::ANNOUNCE, $context)) { 415 | return; 416 | } 417 | 418 | $event = new AnnounceEvent($subject, $marking, $initialTransition, $this, $context); 419 | 420 | $this->dispatcher->dispatch($event, WorkflowEvents::ANNOUNCE); 421 | $this->dispatcher->dispatch($event, \sprintf('workflow.%s.announce', $this->name)); 422 | 423 | foreach ($this->getEnabledTransitions($subject) as $transition) { 424 | $this->dispatcher->dispatch($event, \sprintf('workflow.%s.announce.%s', $this->name, $transition->getName())); 425 | } 426 | } 427 | 428 | private function shouldDispatchEvent(string $eventName, array $context): bool 429 | { 430 | if (null === $this->dispatcher) { 431 | return false; 432 | } 433 | 434 | if ($context[self::DISABLE_EVENTS_MAPPING[$eventName]] ?? false) { 435 | return false; 436 | } 437 | 438 | if (null === $this->eventsToDispatch) { 439 | return true; 440 | } 441 | 442 | if ([] === $this->eventsToDispatch) { 443 | return false; 444 | } 445 | 446 | return \in_array($eventName, $this->eventsToDispatch, true); 447 | } 448 | } 449 | --------------------------------------------------------------------------------