├── config └── machine.php ├── CHANGELOG.md ├── src ├── Behavior │ ├── ResultBehavior.php │ ├── ActionBehavior.php │ ├── CalculatorBehavior.php │ ├── GuardBehavior.php │ ├── ValidationGuardBehavior.php │ ├── EventBehavior.php │ └── InvokableBehavior.php ├── EventCollection.php ├── Enums │ ├── SourceType.php │ ├── TransitionProperty.php │ ├── StateDefinitionType.php │ ├── BehaviorType.php │ └── InternalEvent.php ├── Exceptions │ ├── MachineContextValidationException.php │ ├── MachineEventValidationException.php │ ├── MachineValidationException.php │ ├── BehaviorNotFoundException.php │ ├── MachineDefinitionNotFoundException.php │ ├── MachineAlreadyRunningException.php │ ├── RestoringStateException.php │ ├── MissingMachineContextException.php │ ├── NoStateDefinitionFoundException.php │ ├── NoTransitionDefinitionFoundException.php │ └── InvalidFinalStateDefinitionException.php ├── EventMachine.php ├── Facades │ └── EventMachine.php ├── Definition │ ├── EventDefinition.php │ ├── TransitionBranch.php │ ├── TransitionDefinition.php │ ├── StateDefinition.php │ └── MachineDefinition.php ├── Factories │ └── EventFactory.php ├── Transformers │ └── ModelTransformer.php ├── MachineServiceProvider.php ├── Commands │ ├── MachineClassVisitor.php │ ├── GenerateUmlCommand.php │ └── MachineConfigValidatorCommand.php ├── Traits │ ├── ResolvesBehaviors.php │ ├── HasMachines.php │ └── Fakeable.php ├── Casts │ └── MachineCast.php ├── Models │ └── MachineEvent.php ├── Actor │ ├── State.php │ └── Machine.php ├── ContextManager.php └── StateConfigValidator.php ├── infection.json5 ├── LICENSE.md ├── database ├── migrations │ └── create_machine_events_table.php.stub └── factories │ └── MachineEventFactory.php ├── pint.json ├── composer.json ├── CLAUDE.md └── README.md /config/machine.php: -------------------------------------------------------------------------------- 1 | $attributes 21 | */ 22 | public function newModel(array $attributes = []): EventBehavior 23 | { 24 | /** @var EventBehavior $model */ 25 | $model = $this->modelName(); 26 | 27 | return $model::from($attributes); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Exceptions/RestoringStateException.php: -------------------------------------------------------------------------------- 1 | id; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Exceptions/NoStateDefinitionFoundException.php: -------------------------------------------------------------------------------- 1 | 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /database/migrations/create_machine_events_table.php.stub: -------------------------------------------------------------------------------- 1 | ulid('id')->primary(); 12 | $table->unsignedInteger('sequence_number')->index(); 13 | $table->dateTime('created_at')->index(); 14 | 15 | $table->string('machine_id')->index(); 16 | $table->json('machine_value')->index(); 17 | $table->ulid('root_event_id')->nullable()->index(); 18 | 19 | $table->string('source')->index(); 20 | $table->string('type')->index(); 21 | $table->json('payload')->nullable(); 22 | $table->unsignedInteger('version'); 23 | 24 | $table->json('context')->nullable(); 25 | $table->json('meta')->nullable(); 26 | }); 27 | } 28 | 29 | public function down(): void 30 | { 31 | Schema::dropIfExists('machine_events'); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /src/MachineServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('event-machine') 35 | ->hasConfigFile('machine') 36 | ->hasMigration('create_machine_events_table') 37 | ->hasCommand(GenerateUmlCommand::class) 38 | ->hasCommand(MachineConfigValidatorCommand::class); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Enums/BehaviorType.php: -------------------------------------------------------------------------------- 1 | CalculatorBehavior::class, 37 | self::Guard => GuardBehavior::class, 38 | self::Action => ActionBehavior::class, 39 | self::Result => ResultBehavior::class, 40 | self::Event => EventBehavior::class, 41 | self::Context => ContextManager::class, 42 | }; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Commands/MachineClassVisitor.php: -------------------------------------------------------------------------------- 1 | currentFile = $file; 21 | $this->machineClasses = []; 22 | $this->currentNamespace = null; 23 | } 24 | 25 | public function enterNode(Node $node): mixed 26 | { 27 | if ($node instanceof Namespace_) { 28 | $this->currentNamespace = $node->name?->toString(); 29 | } 30 | 31 | if ($node instanceof Class_ && !$node->isAbstract()) { 32 | $extends = $node->extends?->toString(); 33 | if ($extends === 'Machine' || $extends === '\\Tarfinlabs\\EventMachine\\Actor\\Machine') { 34 | $className = $node->name->toString(); 35 | if ($this->currentNamespace) { 36 | $className = $this->currentNamespace.'\\'.$className; 37 | } 38 | $this->machineClasses[] = $className; 39 | } 40 | } 41 | 42 | return null; 43 | } 44 | 45 | public function getMachineClasses(): array 46 | { 47 | return $this->machineClasses; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidFinalStateDefinitionException.php: -------------------------------------------------------------------------------- 1 | Ulid::generate(), 27 | 'sequence_number' => $this->faker->numberBetween(1, 100), 28 | 'created_at' => $this->faker->dateTime(), 29 | 'machine_id' => implode( 30 | separator: '_', 31 | array: $this->faker->words($this->faker->numberBetween(1, 3)) 32 | ).'_machine', 33 | 'machine_value' => [ 34 | $this->faker->word().'_state', 35 | ], 36 | 'type' => mb_strtoupper( 37 | implode( 38 | separator: '_', 39 | array: $this->faker->words($this->faker->numberBetween(1, 3)) 40 | ).'_EVENT', 41 | ), 42 | 'payload' => [ 43 | $this->faker->word(), 44 | $this->faker->word(), 45 | ], 46 | 'version' => $this->faker->numberBetween(1, 100), 47 | 'context' => [ 48 | $this->faker->word(), 49 | $this->faker->word(), 50 | ], 51 | ]; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Traits/ResolvesBehaviors.php: -------------------------------------------------------------------------------- 1 | behavior ?? []; 23 | 24 | [$type, $name] = explode('.', $path); 25 | 26 | if (!isset($behaviors[$type][$name])) { 27 | throw BehaviorNotFoundException::build($path); 28 | } 29 | 30 | return $behaviors[$type][$name]; 31 | } 32 | 33 | /** 34 | * Get a calculator behavior. 35 | */ 36 | public static function getCalculator(string $name): ?callable 37 | { 38 | return static::getBehavior(BehaviorType::Calculator->value.'.'.$name); 39 | } 40 | 41 | /** 42 | * Get a guard behavior. 43 | */ 44 | public static function getGuard(string $name): ?callable 45 | { 46 | return static::getBehavior(BehaviorType::Guard->value.'.'.$name); 47 | } 48 | 49 | /** 50 | * Get an action behavior. 51 | */ 52 | public static function getAction(string $name): ?callable 53 | { 54 | return static::getBehavior(BehaviorType::Action->value.'.'.$name); 55 | } 56 | 57 | /** 58 | * Get an event behavior. 59 | */ 60 | public static function getEvent(string $name): ?EventBehavior 61 | { 62 | return static::getBehavior(BehaviorType::Event->value.'.'.$name); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Casts/MachineCast.php: -------------------------------------------------------------------------------- 1 | $attributes 24 | * 25 | * @throws BehaviorNotFoundException 26 | * @throws RestoringStateException 27 | */ 28 | public function get( 29 | Model $model, 30 | string $key, 31 | mixed $value, 32 | array $attributes 33 | ): ?Machine { 34 | if ( 35 | in_array(HasMachines::class, class_uses($model), true) && 36 | $model->shouldInitializeMachine() === false 37 | ) { 38 | return null; 39 | } 40 | 41 | /** @var \Tarfinlabs\EventMachine\Actor\Machine $machineClass */ 42 | [$machineClass, $contextKey] = explode(':', $model->getCasts()[$key]); 43 | 44 | $machine = $machineClass::create(state: $value); 45 | 46 | $machine->state->context->set($contextKey, $model); 47 | 48 | return $machine; 49 | } 50 | 51 | /** 52 | * Transform the attribute to its underlying model values. 53 | * 54 | * @param TSet|null $value 55 | * @param array $attributes 56 | */ 57 | public function set(Model $model, string $key, mixed $value, array $attributes): ?string 58 | { 59 | if ($value === null) { 60 | return null; 61 | } 62 | 63 | if (is_string($value)) { 64 | return $value; 65 | } 66 | 67 | return $value->state->history->first()->root_event_id; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Enums/InternalEvent.php: -------------------------------------------------------------------------------- 1 | classBasename()->toString(); 53 | } 54 | 55 | return Str::swap([ 56 | '{machine}' => $machineId, 57 | '{placeholder}' => $placeholder, 58 | ], $this->value); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel", 3 | "rules": { 4 | "declare_strict_types": true, 5 | "not_operator_with_successor_space": false, 6 | "binary_operator_spaces": { 7 | "default": "single_space", 8 | "operators": { 9 | "=>": "align_single_space_minimal", 10 | "=": "align_single_space_minimal" 11 | } 12 | }, 13 | "ordered_imports": { 14 | "sort_algorithm": "length" 15 | }, 16 | "phpdoc_summary": true, 17 | "phpdoc_to_comment": { 18 | "ignored_tags": [ 19 | "see" 20 | ] 21 | }, 22 | "phpdoc_separation": { 23 | "groups": [ 24 | [ 25 | "deprecated", 26 | "link", 27 | "see", 28 | "since" 29 | ], 30 | [ 31 | "author", 32 | "copyright", 33 | "license" 34 | ], 35 | [ 36 | "category", 37 | "package", 38 | "subpackage" 39 | ], 40 | [ 41 | "property", 42 | "property-read", 43 | "property-write" 44 | ], 45 | [ 46 | "param" 47 | ], 48 | [ 49 | "return" 50 | ], 51 | [ 52 | "throws" 53 | ] 54 | ] 55 | }, 56 | "no_superfluous_phpdoc_tags": { 57 | "allow_mixed": true 58 | }, 59 | "phpdoc_line_span": { 60 | "const": "single", 61 | "property": "single" 62 | }, 63 | "phpdoc_var_annotation_correct_order": true, 64 | "php_unit_fqcn_annotation": true, 65 | "php_unit_method_casing": { 66 | "case": "snake_case" 67 | }, 68 | "void_return": true, 69 | "explicit_string_variable": true, 70 | "method_chaining_indentation": true, 71 | "curly_braces_position": { 72 | "control_structures_opening_brace": "same_line", 73 | "functions_opening_brace": "next_line_unless_newline_at_signature_end", 74 | "anonymous_functions_opening_brace": "same_line", 75 | "classes_opening_brace": "next_line_unless_newline_at_signature_end", 76 | "anonymous_classes_opening_brace": "same_line", 77 | "allow_single_line_empty_anonymous_classes": true, 78 | "allow_single_line_anonymous_functions": true 79 | }, 80 | "class_attributes_separation": { 81 | "elements": { 82 | "const": "one", 83 | "method": "one", 84 | "property": "none", 85 | "trait_import": "none" 86 | } 87 | }, 88 | "new_with_parentheses": true 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Traits/HasMachines.php: -------------------------------------------------------------------------------- 1 | getCasts() as $attribute => $cast) { 26 | if ( 27 | !isset($model->attributes[$attribute]) && 28 | is_subclass_of(explode(':', $cast)[0], Machine::class) && 29 | $model->shouldInitializeMachine() 30 | ) { 31 | $machine = $model->$attribute; 32 | 33 | $model->attributes[$attribute] = $machine->state->history->first()->root_event_id; 34 | } 35 | } 36 | }); 37 | } 38 | 39 | public function getAttribute($key) 40 | { 41 | $attribute = parent::getAttribute($key); 42 | 43 | if ($this->shouldInitializeMachine() === true) { 44 | 45 | $machine = $this->findMachine($key); 46 | 47 | if ($machine !== null) { 48 | /** @var \Tarfinlabs\EventMachine\Actor\Machine $machineClass */ 49 | [$machineClass, $contextKey] = explode(':', $machine); 50 | 51 | $machine = $machineClass::create(state: $attribute); 52 | 53 | $machine->state->context->set($contextKey, $this); 54 | 55 | return $machine; 56 | } 57 | } 58 | 59 | return $attribute; 60 | } 61 | 62 | /** 63 | * Determines whether the machine should be initialized. 64 | * 65 | * @return bool Returns true if the machine should be initialized, false otherwise. 66 | */ 67 | public function shouldInitializeMachine(): bool 68 | { 69 | return true; 70 | } 71 | 72 | /** 73 | * Checks if the machine configuration exists for the given key 74 | * either in the `machines` method or the `machines` property of the model. 75 | */ 76 | private function findMachine($key): ?string 77 | { 78 | if (method_exists($this, 'machines')) { 79 | $machines = $this->machines(); 80 | 81 | if (array_key_exists($key, $machines)) { 82 | return $machines[$key]; 83 | } 84 | } 85 | 86 | if (property_exists($this, 'machines')) { 87 | $machines = $this->machines; 88 | 89 | if (array_key_exists($key, $machines)) { 90 | return $machines[$key]; 91 | } 92 | } 93 | 94 | return null; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tarfin-labs/event-machine", 3 | "description": "An event-driven state machine library for PHP, providing an expressive language to define and manage application states, enabling developers to create complex workflows with ease and maintainability.", 4 | "keywords": [ 5 | "tarfin-labs", 6 | "laravel", 7 | "event-machine" 8 | ], 9 | "homepage": "https://github.com/tarfin-labs/event-machine", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Yunus Emre Deligöz", 14 | "email": "ye@deligoz.me", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.2|^8.3|^8.4", 20 | "illuminate/contracts": "^10.48.4|^11.0.8|^12.0", 21 | "nikic/php-parser": "^5.4", 22 | "spatie/laravel-data": "^4.13.0", 23 | "spatie/laravel-package-tools": "^1.14.0" 24 | }, 25 | "require-dev": { 26 | "infection/infection": "^0.29.6", 27 | "laravel/pint": "^1.0", 28 | "larastan/larastan": "^3.1.0", 29 | "orchestra/testbench": "^8.23.1|^9.0|^10.0", 30 | "pestphp/pest": "^3.0", 31 | "pestphp/pest-plugin-arch": "^3.0", 32 | "pestphp/pest-plugin-laravel": "^3.0", 33 | "pestphp/pest-plugin-type-coverage": "^3.0" 34 | }, 35 | "autoload": { 36 | "psr-4": { 37 | "Tarfinlabs\\EventMachine\\": "src", 38 | "Tarfinlabs\\EventMachine\\Database\\Factories\\": "database/factories" 39 | } 40 | }, 41 | "autoload-dev": { 42 | "psr-4": { 43 | "Tarfinlabs\\EventMachine\\Tests\\": "tests" 44 | } 45 | }, 46 | "scripts": { 47 | "post-autoload-dump": "@php ./vendor/bin/testbench package:discover --ansi", 48 | "larastan": "vendor/bin/phpstan analyse --configuration=phpstan.neon.dist", 49 | "pest": "vendor/bin/pest --colors=always --order-by=random --configuration=phpunit.xml.dist", 50 | "test": "@pest", 51 | "testp": "vendor/bin/pest --parallel --colors=always --order-by=random --configuration=phpunit.xml.dist", 52 | "type": "vendor/bin/pest --type-coverage --colors=always --order-by=random --configuration=phpunit.xml.dist", 53 | "profile": "vendor/bin/pest --profile --colors=always --order-by=random --configuration=phpunit.xml.dist", 54 | "coverage": "vendor/bin/pest --coverage --colors=always --order-by=random --configuration=phpunit.xml.dist", 55 | "coveragep": "vendor/bin/pest --parallel --coverage --colors=always --order-by=random --configuration=phpunit.xml.dist", 56 | "pint": "vendor/bin/pint --config=pint.json", 57 | "lint": "@pint", 58 | "lintc": "vendor/bin/pint && (git diff-index --quiet HEAD || (git add . && git commit -m 'chore: Fix styling'))", 59 | "infection": "vendor/bin/pest --mutate --parallel --everything" 60 | }, 61 | "config": { 62 | "sort-packages": true, 63 | "allow-plugins": { 64 | "pestphp/pest-plugin": true, 65 | "infection/extension-installer": true 66 | } 67 | }, 68 | "extra": { 69 | "laravel": { 70 | "providers": [ 71 | "Tarfinlabs\\EventMachine\\MachineServiceProvider" 72 | ], 73 | "aliases": { 74 | "EventMachine": "Tarfinlabs\\EventMachine\\Facades\\EventMachine" 75 | } 76 | } 77 | }, 78 | "minimum-stability": "dev", 79 | "prefer-stable": true 80 | } 81 | -------------------------------------------------------------------------------- /src/Models/MachineEvent.php: -------------------------------------------------------------------------------- 1 | 'string', 59 | 'sequence_number' => 'integer', 60 | 'created_at' => 'datetime', 61 | 'machine_id' => 'string', 62 | 'machine_value' => 'array', 63 | 'root_event_id' => 'string', 64 | 'source' => SourceType::class, 65 | 'type' => 'string', 66 | 'payload' => 'array', 67 | 'version' => 'integer', 68 | 'context' => 'array', 69 | 'meta' => 'array', 70 | ]; 71 | 72 | /** 73 | * Create a new instance of MachineEventFactory. 74 | * 75 | * @return MachineEventFactory The newly created MachineEventFactory instance. 76 | */ 77 | protected static function newFactory(): MachineEventFactory 78 | { 79 | return MachineEventFactory::new(); 80 | } 81 | 82 | /** 83 | * Create a new collection of models. 84 | * 85 | * This method overrides the default Eloquent collection with a custom 86 | * EventCollection. This allows for additional methods to be available 87 | * on the collection of MachineEvent models. 88 | * 89 | * @param array $models An array of MachineEvent models. 90 | * 91 | * @return EventCollection A new instance of EventCollection. 92 | */ 93 | public function newCollection(array $models = []): EventCollection 94 | { 95 | return new EventCollection($models); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Traits/Fakeable.php: -------------------------------------------------------------------------------- 1 | $mock); 27 | 28 | return $mock; 29 | } 30 | 31 | /** 32 | * Check if the behavior is faked. 33 | */ 34 | public static function isFaked(): bool 35 | { 36 | return isset(static::$fakes[static::class]); 37 | } 38 | 39 | /** 40 | * Get the fake instance if exists. 41 | */ 42 | public static function getFake(): ?MockInterface 43 | { 44 | return static::$fakes[static::class] ?? null; 45 | } 46 | 47 | /** 48 | * Remove the fake instance from Laravel's container. 49 | * 50 | * This method handles the cleanup of fake instances from Laravel's service container 51 | * to prevent memory leaks and ensure proper state reset between tests. 52 | */ 53 | protected static function cleanupLaravelContainer(string $class): void 54 | { 55 | if (App::has($class)) { 56 | App::forgetInstance($class); 57 | App::offsetUnset($class); 58 | } 59 | } 60 | 61 | /** 62 | * Clean up Mockery expectations for a given mock instance. 63 | * 64 | * This method resets all expectations on a mock object by: 65 | * 1. Getting all methods that have expectations 66 | * 2. Creating new empty expectation directors for each method 67 | * 3. Performing mockery teardown 68 | * 69 | * @param MockInterface $mock The mock instance to clean up 70 | */ 71 | protected static function cleanupMockeryExpectations(MockInterface $mock): void 72 | { 73 | foreach (array_keys($mock->mockery_getExpectations()) as $method) { 74 | $mock->mockery_setExpectationsFor( 75 | $method, 76 | new Mockery\ExpectationDirector($method, $mock), 77 | ); 78 | } 79 | 80 | $mock->mockery_teardown(); 81 | } 82 | 83 | /** 84 | * Reset all fakes. 85 | */ 86 | public static function resetFakes(): void 87 | { 88 | if (isset(static::$fakes[static::class])) { 89 | $mock = static::$fakes[static::class]; 90 | 91 | self::cleanupLaravelContainer(class: static::class); 92 | self::cleanupMockeryExpectations($mock); 93 | 94 | unset(static::$fakes[static::class]); 95 | } 96 | } 97 | 98 | /** 99 | * Reset all fakes in application container. 100 | */ 101 | public static function resetAllFakes(): void 102 | { 103 | foreach (static::$fakes as $class => $mock) { 104 | self::cleanupLaravelContainer(class: $class); 105 | self::cleanupMockeryExpectations($mock); 106 | } 107 | 108 | static::$fakes = []; 109 | } 110 | 111 | /** 112 | * Set run expectations for the fake behavior. 113 | */ 114 | public static function shouldRun(): Mockery\Expectation|Mockery\CompositeExpectation 115 | { 116 | return static::fake()->shouldReceive('__invoke'); 117 | } 118 | 119 | /** 120 | * Set return value for the fake behavior. 121 | */ 122 | public static function shouldReturn(mixed $value): void 123 | { 124 | static::fake()->shouldReceive('__invoke')->andReturn($value); 125 | } 126 | 127 | /** 128 | * Assert that the behavior was run. 129 | */ 130 | public static function assertRan(): void 131 | { 132 | if (!isset(static::$fakes[static::class])) { 133 | throw new RuntimeException(message: 'Behavior '.static::class.' was not faked.'); 134 | } 135 | 136 | static::$fakes[static::class]->shouldHaveReceived('__invoke'); 137 | } 138 | 139 | /** 140 | * Assert that the behavior was not run. 141 | */ 142 | public static function assertNotRan(): void 143 | { 144 | if (!isset(static::$fakes[static::class])) { 145 | throw new RuntimeException(message: 'Behavior '.static::class.' was not faked.'); 146 | } 147 | 148 | static::$fakes[static::class]->shouldNotHaveReceived('__invoke'); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Development Commands 6 | 7 | ### Testing 8 | - `composer test` or `composer pest` - Run all tests using Pest 9 | - `composer testp` - Run tests in parallel 10 | - `composer coverage` - Run tests with coverage report 11 | - `composer coveragep` - Run tests with coverage in parallel 12 | - `composer type` - Run tests with type coverage 13 | - `composer profile` - Run tests with profiling 14 | 15 | ### Code Quality 16 | - `composer lint` or `composer pint` - Fix code style using Laravel Pint 17 | - `composer lintc` - Fix code style and commit changes 18 | - `composer larastan` - Run static analysis with PHPStan/Larastan 19 | - `composer infection` - Run mutation testing 20 | 21 | ### Artisan Commands 22 | - `php artisan machine:generate-uml` - Generate UML diagrams for state machines 23 | - `php artisan machine:validate-config` - Validate machine configuration 24 | 25 | ## Architecture Overview 26 | 27 | EventMachine is a Laravel package for creating event-driven state machines, heavily influenced by XState. The core architecture consists of: 28 | 29 | ### Core Components 30 | 31 | **MachineDefinition** (`src/Definition/MachineDefinition.php`): The blueprint for state machines, containing: 32 | - Configuration parsing and validation 33 | - State definitions and transitions 34 | - Behavior resolution (actions, guards, events) 35 | - Event queue management 36 | - Context initialization 37 | 38 | **Machine** (`src/Actor/Machine.php`): Runtime instance that executes state machines: 39 | - State persistence and restoration 40 | - Event handling and transitions 41 | - Database integration with machine_events table 42 | - Validation guard processing 43 | 44 | **State** (`src/Actor/State.php`): Represents current machine state: 45 | - Current state definition 46 | - Context data management 47 | - Event behavior tracking 48 | - History maintenance 49 | 50 | ### Behavior System 51 | 52 | All machine behaviors extend `InvokableBehavior` and include: 53 | - **Actions** (`src/Behavior/ActionBehavior.php`): Execute side effects during transitions 54 | - **Guards** (`src/Behavior/GuardBehavior.php`): Control transition execution with conditions 55 | - **Events** (`src/Behavior/EventBehavior.php`): Define event structure and validation 56 | - **Results** (`src/Behavior/ResultBehavior.php`): Compute final state machine outputs 57 | 58 | ### State Management 59 | 60 | - **StateDefinition** (`src/Definition/StateDefinition.php`): Defines state behavior, transitions, and hierarchy 61 | - **TransitionDefinition** (`src/Definition/TransitionDefinition.php`): Defines state transitions with conditions 62 | - **ContextManager** (`src/ContextManager.php`): Manages machine context data with validation 63 | 64 | ### Database Integration 65 | 66 | - Machine events are persisted in `machine_events` table via `MachineEvent` model 67 | - State can be restored from any point using root event IDs 68 | - Incremental context changes are stored to optimize database usage 69 | 70 | ## Key Development Patterns 71 | 72 | ### Machine Definition Structure 73 | ```php 74 | MachineDefinition::define( 75 | config: [ 76 | 'id' => 'machine_name', 77 | 'initial' => 'initial_state', 78 | 'context' => [...], 79 | 'states' => [...] 80 | ], 81 | behavior: [ 82 | 'actions' => [...], 83 | 'guards' => [...], 84 | 'events' => [...] 85 | ] 86 | ) 87 | ``` 88 | 89 | ### Invokable Behaviors 90 | All behaviors should extend appropriate base classes: 91 | - Actions extend `ActionBehavior` 92 | - Guards extend `GuardBehavior` or `ValidationGuardBehavior` 93 | - Events extend `EventBehavior` 94 | 95 | ### Testing Structure 96 | - Test stubs in `tests/Stubs/` provide examples of machine implementations 97 | - Machine examples include TrafficLights, Calculator, Elevator patterns 98 | - Tests use `RefreshDatabase` trait and in-memory SQLite 99 | 100 | ### Code Style 101 | - PHP 8.2+ with strict types enabled 102 | - Laravel Pint with custom alignment rules for `=>` and `=` operators 103 | - PHPStan level 7 analysis 104 | - All classes use declare(strict_types=1) 105 | 106 | ## Package Structure 107 | 108 | - `src/Actor/` - Runtime machine and state classes 109 | - `src/Behavior/` - Base behavior classes and implementations 110 | - `src/Definition/` - Machine definition and configuration classes 111 | - `src/Enums/` - Type definitions and constants 112 | - `src/Exceptions/` - Custom exception classes 113 | - `src/Traits/` - Reusable traits like `Fakeable` and `HasMachines` 114 | - `tests/Stubs/` - Example machine implementations for testing 115 | - `config/machine.php` - Package configuration 116 | - `database/migrations/` - Database schema for machine events 117 | 118 | The package integrates with Laravel through the `MachineServiceProvider` and provides Eloquent model casting via `MachineCast`. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | Where Events Drive the Gears of Progress 6 | 7 | 8 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/tarfin-labs/event-machine.svg?style=flat-square)](https://packagist.org/packages/tarfin-labs/event-machine) 9 | [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/tarfin-labs/event-machine/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/tarfin-labs/event-machine/actions?query=workflow%3Arun-tests+branch%3Amain) 10 | [![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/tarfin-labs/event-machine/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/tarfin-labs/event-machine/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) 11 | [![Total Downloads](https://img.shields.io/packagist/dt/tarfin-labs/event-machine.svg?style=flat-square)](https://packagist.org/packages/tarfin-labs/event-machine) 12 | 13 |
14 | 15 | EventMachine is a PHP library for creating and managing event-driven state machines. It is designed to be simple and easy to use, while providing powerful functionality for managing complex state transitions. This library is heavily influenced by XState, a popular JavaScript state machine library. 16 | 17 | ```mermaid 18 | --- 19 | title: traffic_lights_machine 20 | --- 21 | stateDiagram-v2 22 | [*] --> Red 23 | Red --> Yellow: TIMER_RED
do / wait_for_red_light 24 | Yellow --> Green: TIMER_YELLOW
do / wait_for_yellow_light 25 | Green --> Red: TIMER_GREEN
do / wait_for_green_light 26 | 27 | Red --> PowerOff: [is_power_off] 28 | Yellow --> PowerOff: [is_power_off] 29 | Green --> PowerOff: [is_power_off] 30 | 31 | Red : Red
entry / turn_on_red_light
exit / turn_off_red_light 32 | Yellow : Yellow
entry / turn_on_yellow_light
exit / turn_off_yellow_light 33 | Green : Green
entry / turn_on_green_light
exit / turn_off_green_light 34 | ``` 35 | 36 | ## Installation 37 | 38 | You can install the package via composer: 39 | 40 | ```bash 41 | composer require tarfin-labs/event-machine 42 | ``` 43 | 44 | You can publish and run the migrations with: 45 | 46 | ```bash 47 | php artisan vendor:publish --tag="event-machine-migrations" 48 | php artisan migrate 49 | ``` 50 | 51 | You can publish the config file with: 52 | 53 | ```bash 54 | php artisan vendor:publish --tag="event-machine-config" 55 | ``` 56 | 57 | This is the contents of the published config file: 58 | 59 | ```php 60 | return [ 61 | ]; 62 | ``` 63 | 64 | ## Usage 65 | 66 | ```php 67 | $machine = MachineDefinition::define( 68 | config: [ 69 | 'initial' => 'green', 70 | 'context' => [ 71 | 'value' => 1, 72 | ], 73 | 'states' => [ 74 | 'green' => [ 75 | 'on' => [ 76 | 'TIMER' => [ 77 | [ 78 | 'target' => 'yellow', 79 | 'guards' => 'isOneGuard', 80 | ], 81 | [ 82 | 'target' => 'red', 83 | 'guards' => 'isTwoGuard', 84 | ], 85 | [ 86 | 'target' => 'pedestrian', 87 | ], 88 | ], 89 | ], 90 | ], 91 | 'yellow' => [], 92 | 'red' => [], 93 | 'pedestrian' => [], 94 | ], 95 | ], 96 | behavior: [ 97 | 'guards' => [ 98 | 'isOneGuard' => function (ContextManager $context, array $event): bool { 99 | return $context->get('value') === 1; 100 | }, 101 | 'isTwoGuard' => function (ContextManager $context, array $event): bool { 102 | return $context->get('value') === 2; 103 | }, 104 | ], 105 | ], 106 | ); 107 | ``` 108 | 109 | ## Testing 110 | 111 | ```bash 112 | composer test 113 | ``` 114 | 115 | ## Changelog 116 | 117 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 118 | 119 | ## Contributing 120 | 121 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 122 | 123 | ## Security Vulnerabilities 124 | 125 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 126 | 127 | ## Credits 128 | 129 | - [Yunus Emre Deligöz](https://github.com/deligoez) 130 | - [Fatih Aydın](https://github.com/aydinfatih) 131 | - [All Contributors](../../contributors) 132 | 133 | ## License 134 | 135 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 136 | -------------------------------------------------------------------------------- /src/Commands/GenerateUmlCommand.php: -------------------------------------------------------------------------------- 1 | argument('machine'); 36 | 37 | $machine = $machinePath::create(); 38 | $this->lines[] = '@startuml'; 39 | $this->lines[] = 'skinparam linetype polyline'; 40 | $this->lines[] = ' 41 | 54 | '; 55 | 56 | $this->lines[] = 'set namespaceSeparator none'; 57 | 58 | $this->addTransition(from: '[*]', to: $machine->definition->initialStateDefinition->id); 59 | foreach ($machine->definition->stateDefinitions as $stateDefinition) { 60 | if ($stateDefinition->stateDefinitions !== null) { 61 | $this->colors[$stateDefinition->id] = '#'.substr(dechex(crc32($stateDefinition->id)), 0, 6); 62 | } 63 | 64 | $this->handleStateDefinition(stateDefinition: $stateDefinition); 65 | } 66 | 67 | foreach ($this->colors as $state => $color) { 68 | $this->lines[] = "state {$state}"; 69 | } 70 | 71 | $this->lines[] = '@enduml'; 72 | 73 | $filePath = str_replace( 74 | '\\', 75 | DIRECTORY_SEPARATOR, 76 | $machinePath 77 | ); 78 | 79 | File::put(base_path(dirname($filePath).'/'.$machine->definition->root->key.'-machine.puml'), implode("\r\n", $this->lines)); 80 | } 81 | 82 | private function handleStateDefinition(StateDefinition $stateDefinition): void 83 | { 84 | if ($stateDefinition->stateDefinitions !== null) { 85 | $this->lines[] = "state {$stateDefinition->id} {"; 86 | $this->addTransition(from: '[*]', to: $stateDefinition->initialStateDefinition->id); 87 | foreach ($stateDefinition->stateDefinitions as $childStateDefinition) { 88 | $this->colors[$childStateDefinition->id] = $this->colors[$stateDefinition->id]; 89 | $this->handleStateDefinition(stateDefinition: $childStateDefinition); 90 | } 91 | $this->lines[] = '}'; 92 | } 93 | 94 | $this->handleTransitions(stateDefinition: $stateDefinition); 95 | 96 | if (!in_array("{$stateDefinition->id} : {$stateDefinition->description}", $this->lines)) { 97 | $this->lines[] = "{$stateDefinition->id} : {$stateDefinition->description}"; 98 | } 99 | } 100 | 101 | private function handleTransitions(StateDefinition $stateDefinition): void 102 | { 103 | foreach ($stateDefinition->entry as $entryAction) { 104 | $this->lines[] = "{$stateDefinition->id} : {$entryAction}"; 105 | } 106 | 107 | if ($stateDefinition->transitionDefinitions !== null) { 108 | foreach ($stateDefinition->transitionDefinitions as $event => $transitionDefinition) { 109 | $branches = $transitionDefinition->branches ?? []; 110 | $eventName = str_replace('@', '', $event); 111 | 112 | /** @var \Tarfinlabs\EventMachine\Definition\TransitionBranch $branch */ 113 | foreach ($branches as $branch) { 114 | 115 | $this->addTransition( 116 | from: $stateDefinition, 117 | to: $branch->target, 118 | eventName: $eventName, 119 | ); 120 | } 121 | } 122 | } 123 | } 124 | 125 | private function addTransition( 126 | StateDefinition|string $from, 127 | StateDefinition|string|null $to, 128 | ?string $eventName = '', 129 | string $direction = 'down', 130 | array $attributes = [], 131 | string $arrow = '-' 132 | ): void { 133 | 134 | $from = $from->id ?? $from; 135 | $to = $to?->id ?? $to ?? $from; 136 | 137 | foreach ($this->colors as $id => $transitionColor) { 138 | if (str_starts_with($to, $id)) { 139 | $attributes[] = $transitionColor; 140 | break; 141 | } 142 | } 143 | 144 | $attributeString = ''; 145 | if (!empty($attributes)) { 146 | $attributeString = '['.implode(',', $attributes).']'; 147 | } 148 | 149 | if (empty($eventName)) { 150 | $this->lines[] = "{$from} -{$attributeString}$direction$arrow> {$to}"; 151 | } else { 152 | $this->lines[] = "{$from} -{$attributeString}$direction$arrow> {$to} : [{$eventName}]"; 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/Actor/State.php: -------------------------------------------------------------------------------- 1 | The value to be stored. 30 | */ 31 | public array $value; 32 | 33 | /** 34 | * Constructs a new instance of the class. 35 | * 36 | * @param ContextManager $context The context manager instance. 37 | * @param StateDefinition|null $currentStateDefinition The current state definition, or null if not set. 38 | * @param EventBehavior|null $currentEventBehavior The current event behavior, or null if not set. 39 | * @param Collection|null $history The history collection, or null if not set. 40 | */ 41 | public function __construct( 42 | public ContextManager $context, 43 | public ?StateDefinition $currentStateDefinition, 44 | public ?EventBehavior $currentEventBehavior = null, 45 | public ?EventCollection $history = null, 46 | ) { 47 | $this->history ??= new EventCollection(); 48 | 49 | $this->updateMachineValueFromState(); 50 | } 51 | 52 | /** 53 | * Updates the machine value based on the current state definition. 54 | */ 55 | protected function updateMachineValueFromState(): void 56 | { 57 | $this->value = [$this->currentStateDefinition->id]; 58 | } 59 | 60 | /** 61 | * Sets the current state definition for the machine. 62 | * 63 | * @param StateDefinition $stateDefinition The state definition to set. 64 | * 65 | * @return self The current object instance. 66 | */ 67 | public function setCurrentStateDefinition(StateDefinition $stateDefinition): self 68 | { 69 | $this->currentStateDefinition = $stateDefinition; 70 | $this->updateMachineValueFromState(); 71 | 72 | return $this; 73 | } 74 | 75 | /** 76 | * Sets the internal event behavior for the machine. 77 | * 78 | * @param InternalEvent $type The internal event type. 79 | * @param string|null $placeholder The optional placeholder parameter. 80 | * @param array|null $payload The optional payload array. 81 | * 82 | * @return self The current object instance. 83 | */ 84 | public function setInternalEventBehavior( 85 | InternalEvent $type, 86 | ?string $placeholder = null, 87 | ?array $payload = null, 88 | bool $shouldLog = false, 89 | ): self { 90 | $eventDefinition = new EventDefinition( 91 | type: $type->generateInternalEventName( 92 | machineId: $this->currentStateDefinition->machine->id, 93 | placeholder: $placeholder 94 | ), 95 | payload: $payload, 96 | source: SourceType::INTERNAL, 97 | ); 98 | 99 | return $this->setCurrentEventBehavior(currentEventBehavior: $eventDefinition, shouldLog: $shouldLog); 100 | } 101 | 102 | /** 103 | * Sets the current event behavior for the machine. 104 | * 105 | * @param EventBehavior $currentEventBehavior The event behavior to set. 106 | * 107 | * @return self The current object instance. 108 | */ 109 | public function setCurrentEventBehavior(EventBehavior $currentEventBehavior, bool $shouldLog = false): self 110 | { 111 | $this->currentEventBehavior = $currentEventBehavior; 112 | 113 | $id = Ulid::generate(); 114 | $count = count($this->history) + 1; 115 | 116 | $rootEventId = $this->history->first()->id ?? $id; 117 | 118 | $this->history->push( 119 | new MachineEvent([ 120 | 'id' => $id, 121 | 'sequence_number' => $count, 122 | 'created_at' => now(), 123 | 'machine_id' => $this->currentStateDefinition->machine->id, 124 | 'machine_value' => [$this->currentStateDefinition->id], 125 | 'root_event_id' => $rootEventId, 126 | 'source' => $currentEventBehavior->source, 127 | 'type' => $currentEventBehavior->type, 128 | 'payload' => $currentEventBehavior->payload, 129 | 'version' => $currentEventBehavior->version, 130 | 'context' => $this->context->toArray(), 131 | 'meta' => $this->currentStateDefinition->meta, 132 | ]) 133 | ); 134 | 135 | if ($shouldLog === true) { 136 | Log::debug("[{$rootEventId}] {$currentEventBehavior->type}"); 137 | } 138 | 139 | return $this; 140 | } 141 | 142 | /** 143 | * Checks if the given value matches the current state's value. 144 | * 145 | * @param string $value The value to be checked. 146 | * 147 | * @return bool Returns true if the value matches the current state's value; otherwise, returns false. 148 | */ 149 | public function matches(string $value): bool 150 | { 151 | $machineId = $this->currentStateDefinition->machine->id; 152 | 153 | if (!str_starts_with($value, $machineId)) { 154 | $value = $machineId.'.'.$value; 155 | } 156 | 157 | return $this->value[0] === $value; 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/Behavior/EventBehavior.php: -------------------------------------------------------------------------------- 1 | type === null) { 74 | $this->type = static::getType(); 75 | } 76 | 77 | if ($isTransactional !== null) { 78 | $this->isTransactional = $isTransactional; 79 | } 80 | 81 | $this->actor = $actor; 82 | } 83 | 84 | /** 85 | * Gets the type of the object. 86 | * 87 | * @return string The type of the object. 88 | */ 89 | abstract public static function getType(): string; 90 | 91 | /** 92 | * Validates the object by calling the static validate() method and handles any validation exceptions. 93 | * 94 | * @throws MachineEventValidationException If the object fails validation. 95 | */ 96 | public function selfValidate(): void 97 | { 98 | try { 99 | static::validate($this); 100 | } catch (ValidationException $e) { 101 | throw new MachineEventValidationException($e->validator); 102 | } 103 | } 104 | 105 | public function actor(ContextManager $context): mixed 106 | { 107 | return $this->actor; 108 | } 109 | 110 | /** 111 | * Retrieves the scenario value from the payload. 112 | * 113 | * @return string|null The scenario value if available, otherwise null. 114 | */ 115 | public function getScenario(): ?string 116 | { 117 | return $this->payload['scenarioType'] ?? null; 118 | } 119 | 120 | /** 121 | * Indicates if the validator should stop on the first rule failure. 122 | * 123 | * @return bool Returns true by default. 124 | */ 125 | public static function stopOnFirstFailure(): bool 126 | { 127 | return true; 128 | } 129 | 130 | /** 131 | * Delegate to parent's static collect() from Spatie Data class. 132 | * The trait's non-static collect() is aliased as 'collection' to avoid conflict. 133 | */ 134 | public static function collect(...$args): array|DataCollection|PaginatedDataCollection|CursorPaginatedDataCollection|Enumerable|AbstractPaginator|PaginatorContract|AbstractCursorPaginator|CursorPaginatorContract|LazyCollection|Collection 135 | { 136 | return parent::collect(...$args); 137 | } 138 | 139 | /** 140 | * Override only() to return static type for fluent interface. 141 | * Uses parent implementation which correctly returns EventBehavior instance. 142 | */ 143 | public function only(...$args): static 144 | { 145 | return parent::only(...$args); 146 | } 147 | 148 | /** 149 | * Override except() to return static type for fluent interface. 150 | * Uses parent implementation which correctly returns EventBehavior instance. 151 | */ 152 | public function except(...$args): static 153 | { 154 | return parent::except(...$args); 155 | } 156 | 157 | /** 158 | * Get all of the input and files for the request. 159 | * 160 | * @param array|mixed|null $keys 161 | */ 162 | public function all($keys = null): array 163 | { 164 | $input = $this->payload ?? []; 165 | 166 | if (!$keys) { 167 | return $input; 168 | } 169 | 170 | $results = []; 171 | 172 | foreach (is_array($keys) ? $keys : func_get_args() as $key) { 173 | Arr::set($results, $key, Arr::get($input, $key)); 174 | } 175 | 176 | return $results; 177 | } 178 | 179 | /** 180 | * Retrieve data from the instance. 181 | * 182 | * @param string $key 183 | * @param mixed $default 184 | */ 185 | public function data($key = null, $default = null): mixed 186 | { 187 | return data_get($this->all(), $key, $default); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/ContextManager.php: -------------------------------------------------------------------------------- 1 | Arr::get($this->data, $key), 44 | is_subclass_of($this, self::class) => $this->$key, 45 | }; 46 | } 47 | 48 | /** 49 | * Sets a value for a given key. 50 | * 51 | * This method is used to set a value for a given key in the data array. 52 | * If the current class is the same as the class of the object, the 53 | * value is set in the data array. If the current class is a 54 | * subclass of the class of the object, the value is set as 55 | * a property of the object. 56 | * 57 | * @param string $key The key for which to set the value. 58 | * @param mixed $value The value to set for the given key. 59 | */ 60 | public function set(string $key, mixed $value): mixed 61 | { 62 | if ($this->data instanceof Optional) { 63 | return null; 64 | } 65 | 66 | match (true) { 67 | get_class($this) === self::class => $this->data[$key] = $value, 68 | is_subclass_of($this, self::class) => $this->$key = $value, 69 | }; 70 | 71 | return $value; 72 | } 73 | 74 | /** 75 | * Determines if a key-value pair exists and optionally checks its type. 76 | * 77 | * This method checks if the context contains the given key and, if a type is 78 | * specified, whether the value associated with that key is of the given type. 79 | * If no type is specified, only existence is checked. If the key does not 80 | * exist, or if the type does not match, the method returns false. 81 | * 82 | * @param string $key The key to check for existence. 83 | * @param string|null $type The type to check for the value. If null, 84 | * only existence is checked. 85 | * 86 | * @return bool True if the key exists and (if a type is specified) 87 | * its value is of the given type. False otherwise. 88 | */ 89 | public function has(string $key, ?string $type = null): bool 90 | { 91 | $hasKey = match (true) { 92 | get_class($this) === self::class => Arr::has($this->data, $key), 93 | is_subclass_of($this, self::class) => property_exists($this, $key), 94 | }; 95 | 96 | if (!$hasKey || $type === null) { 97 | return $hasKey; 98 | } 99 | 100 | $value = $this->get($key); 101 | $valueType = is_object($value) ? get_class($value) : gettype($value); 102 | 103 | return $valueType === $type; 104 | } 105 | 106 | /** 107 | * Remove a key-value pair from the context by its key. 108 | * 109 | * @param string $key The key to remove from the context. 110 | */ 111 | public function remove(string $key): void 112 | { 113 | unset($this->data[$key]); 114 | } 115 | 116 | /** 117 | * Validates the current instance against its own rules. 118 | * 119 | * This method validates the current instance by calling the static validate() method on itself. 120 | * If validation fails, it throws a MachineContextValidationException with the validator object. 121 | */ 122 | public function selfValidate(): void 123 | { 124 | try { 125 | static::validate($this); 126 | } catch (ValidationException $e) { 127 | throw new MachineContextValidationException($e->validator); 128 | } 129 | } 130 | 131 | /** 132 | * Validates the given payload and creates an instance from it. 133 | * 134 | * This method first validates the given payload using the static validate() method. 135 | * If the validation passes, it creates a new instance of the class using the 136 | * static from() method and returns it. 137 | * If validation fails, it throws a MachineContextValidationException. 138 | * 139 | * @param array|Arrayable $payload The payload to be validated and created from. 140 | * 141 | * @return static A new instance of the class created from the payload. 142 | */ 143 | public static function validateAndCreate(array|Arrayable $payload): static 144 | { 145 | try { 146 | static::validate($payload); 147 | } catch (ValidationException $e) { 148 | throw new MachineContextValidationException($e->validator); 149 | } 150 | 151 | return static::from($payload); 152 | } 153 | 154 | // region Magic Setup 155 | 156 | /** 157 | * Set a value in the context by its name. 158 | * 159 | * @param string $name The name of the value to set. 160 | * @param mixed $value The value to set. 161 | */ 162 | public function __set(string $name, mixed $value): void 163 | { 164 | $this->set($name, $value); 165 | } 166 | 167 | /** 168 | * Magic method to dynamically retrieve a value from the context by its key. 169 | * 170 | * @param string $name The key of the value to retrieve. 171 | * 172 | * @return mixed The value associated with the given key, or null if the key does not exist. 173 | */ 174 | public function __get(string $name): mixed 175 | { 176 | return $this->get($name); 177 | } 178 | 179 | /** 180 | * Checks if a property is set on the object. 181 | * 182 | * @param string $name The name of the property to check. 183 | * 184 | * @return bool True if the property exists and is set, false otherwise. 185 | */ 186 | public function __isset(string $name): bool 187 | { 188 | return $this->has($name); 189 | } 190 | 191 | // endregion 192 | 193 | public function getMorphClass(): string 194 | { 195 | return self::class; 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/Behavior/InvokableBehavior.php: -------------------------------------------------------------------------------- 1 | An array containing the required context and the type of the context for the code to execute correctly. */ 29 | public static array $requiredContext = []; 30 | 31 | /** @var bool Write log in console */ 32 | public bool $shouldLog = false; 33 | 34 | /** 35 | * Constructs a new instance of the class. 36 | * 37 | * @param Collection|null $eventQueue The event queue collection. Default is null. 38 | * 39 | * @return void 40 | */ 41 | public function __construct(protected ?Collection $eventQueue = null) 42 | { 43 | if ($this->eventQueue === null) { 44 | $this->eventQueue = new Collection(); 45 | } 46 | } 47 | 48 | /** 49 | * Raises an event by adding it to the event queue. 50 | * 51 | * @param \Tarfinlabs\EventMachine\Behavior\EventBehavior|array $eventBehavior The event definition object to be raised. 52 | */ 53 | public function raise(EventBehavior|array $eventBehavior): void 54 | { 55 | $this->eventQueue->push($eventBehavior); 56 | } 57 | 58 | /** 59 | * Checks if the given context has any missing attributes. 60 | * 61 | * This method checks if the required context attributes specified in the 62 | * "$requiredContext" property are present in the given context. It returns 63 | * the key of the first missing attribute if any, otherwise it returns null. 64 | * 65 | * @param ContextManager $context The context to be checked. 66 | * 67 | * @return string|null The key of the first missing attribute, or null if all 68 | * required attributes are present. 69 | */ 70 | public static function hasMissingContext(ContextManager $context): ?string 71 | { 72 | // Check if the requiredContext property is an empty array 73 | if (empty(static::$requiredContext)) { 74 | return null; 75 | } 76 | 77 | // Iterate through the required context attributes 78 | /* @var GuardBehavior $guardBehavior */ 79 | foreach (static::$requiredContext as $key => $type) { 80 | // Check if the context manager has the required context attribute 81 | if (!$context->has($key, $type)) { 82 | // Return the key of the missing context attribute 83 | return $key; 84 | } 85 | } 86 | 87 | // Return null if all the required context attributes are present 88 | return null; 89 | } 90 | 91 | /** 92 | * Validates the required context for the behavior. 93 | * 94 | * This method checks if all the required context properties are present 95 | * in the given ContextManager instance. If any required context property is missing, 96 | * it throws a MissingMachineContextException. 97 | * 98 | * @param ContextManager $context The context to be validated. 99 | */ 100 | public static function validateRequiredContext(ContextManager $context): void 101 | { 102 | $missingContext = static::hasMissingContext($context); 103 | 104 | if ($missingContext !== null) { 105 | throw MissingMachineContextException::build($missingContext); 106 | } 107 | } 108 | 109 | /** 110 | * Get the type of the current InvokableBehavior. 111 | * 112 | * This method returns the type of the InvokableBehavior as a string. 113 | * The type is determined by converting the FQCN of the 114 | * InvokableBehavior to base class name as camel case. 115 | * 116 | * @return string The type of the behavior. 117 | */ 118 | public static function getType(): string 119 | { 120 | return Str::of(static::class) 121 | ->classBasename() 122 | ->toString(); 123 | } 124 | 125 | /** 126 | * Injects invokable behavior parameters. 127 | * 128 | * Retrieves the parameters of the given invokable behavior and injects the corresponding values 129 | * based on the provided state, event behavior, and action arguments. 130 | * The injected values are added to an array and returned. 131 | * 132 | * @param callable $actionBehavior The invokable behavior to inject parameters for. 133 | * @param State $state The state object used for parameter matching. 134 | * @param EventBehavior|null $eventBehavior The event behavior used for parameter matching. (Optional) 135 | * @param array|null $actionArguments The action arguments used for parameter matching. (Optional) 136 | * 137 | * @return array The injected invokable behavior parameters. 138 | * 139 | * @throws \ReflectionException 140 | */ 141 | public static function injectInvokableBehaviorParameters( 142 | callable $actionBehavior, 143 | State $state, 144 | ?EventBehavior $eventBehavior = null, 145 | ?array $actionArguments = null, 146 | ): array { 147 | $invocableBehaviorParameters = []; 148 | 149 | $invocableBehaviorReflection = $actionBehavior instanceof self 150 | ? new ReflectionMethod($actionBehavior, '__invoke') 151 | : new ReflectionFunction($actionBehavior); 152 | 153 | foreach ($invocableBehaviorReflection->getParameters() as $parameter) { 154 | $parameterType = $parameter->getType(); 155 | 156 | $typeName = $parameterType instanceof ReflectionUnionType 157 | ? $parameterType->getTypes()[0]->getName() 158 | : $parameterType->getName(); 159 | 160 | $value = match (true) { 161 | is_a($typeName, class: ContextManager::class, allow_string: true) || is_subclass_of($typeName, class: ContextManager::class) => $state->context, // ContextManager 162 | is_a($typeName, class: EventBehavior::class, allow_string: true) || is_subclass_of($typeName, class: EventBehavior::class) => $eventBehavior, // EventBehavior 163 | is_a($state, $typeName) => $state, // State 164 | is_a($state->history, $typeName) => $state->history, // EventCollection 165 | $typeName === 'array' => $actionArguments, // Behavior Arguments 166 | default => null, 167 | }; 168 | 169 | $invocableBehaviorParameters[] = $value; 170 | } 171 | 172 | return $invocableBehaviorParameters; 173 | } 174 | 175 | /** 176 | * Run the behavior with the given arguments. 177 | * 178 | * @param mixed ...$args Arguments to pass to the behavior 179 | */ 180 | public static function run(mixed ...$args): mixed 181 | { 182 | $instance = static::isFaked() 183 | ? App::make(static::class) 184 | : new static(); 185 | 186 | return $instance(...$args); 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/Definition/TransitionBranch.php: -------------------------------------------------------------------------------- 1 | 35 | */ 36 | public ?array $actions = null; 37 | 38 | /** The description of the transition branch. */ 39 | public ?string $description = null; 40 | 41 | // endregion 42 | 43 | // region Constructor 44 | 45 | /** 46 | * Constructs a new TransitionBranch instance. 47 | */ 48 | public function __construct( 49 | public null|string|array $transitionBranchConfig, 50 | public TransitionDefinition $transitionDefinition, 51 | ) { 52 | if ($this->transitionBranchConfig === null) { 53 | $this->target = null; 54 | } 55 | 56 | if (is_string($this->transitionBranchConfig)) { 57 | $targetStateDefinition = $this 58 | ->transitionDefinition 59 | ->source 60 | ->machine 61 | ->getNearestStateDefinitionByString($this->transitionBranchConfig); 62 | 63 | // If the target state definition is not found, throw an exception 64 | if ($targetStateDefinition === null) { 65 | throw NoStateDefinitionFoundException::build( 66 | from: $this->transitionDefinition->source->id, 67 | to: $this->transitionBranchConfig, 68 | eventType: $this->transitionDefinition->event, 69 | ); 70 | } 71 | 72 | $this->target = $targetStateDefinition; 73 | 74 | return; 75 | } 76 | 77 | if (is_array($this->transitionBranchConfig)) { 78 | if (empty($this->target)) { 79 | $this->target = null; 80 | } 81 | 82 | if (isset($this->transitionBranchConfig['target'])) { 83 | $targetStateDefinition = $this->transitionDefinition 84 | ->source 85 | ->machine 86 | ->getNearestStateDefinitionByString($this->transitionBranchConfig['target']); 87 | 88 | if ($targetStateDefinition === null) { 89 | throw NoStateDefinitionFoundException::build( 90 | from: $this->transitionDefinition->source->id, 91 | to: $this->transitionBranchConfig['target'], 92 | eventType: $this->transitionDefinition->event, 93 | ); 94 | } 95 | 96 | $this->target = $targetStateDefinition; 97 | } 98 | 99 | $this->description = $this->transitionBranchConfig['description'] ?? null; 100 | 101 | $this->initializeCalculators(); 102 | $this->initializeGuards(); 103 | $this->initializeActions(); 104 | } 105 | } 106 | 107 | /** 108 | * Initializes calculator behaviors for the transition branch. 109 | * Handles both inline calculators and calculator class definitions. 110 | */ 111 | protected function initializeCalculators(): void 112 | { 113 | if (isset($this->transitionBranchConfig[BehaviorType::Calculator->value])) { 114 | $this->calculators = is_array($this->transitionBranchConfig[BehaviorType::Calculator->value]) 115 | ? $this->transitionBranchConfig[BehaviorType::Calculator->value] 116 | : [$this->transitionBranchConfig[BehaviorType::Calculator->value]]; 117 | 118 | $this->initializeInlineBehaviors( 119 | inlineBehaviors: $this->calculators, 120 | behaviorType: BehaviorType::Calculator 121 | ); 122 | } 123 | } 124 | 125 | /** 126 | * Initializes the guard/s for this transition. 127 | */ 128 | protected function initializeGuards(): void 129 | { 130 | if (isset($this->transitionBranchConfig[BehaviorType::Guard->value])) { 131 | $this->guards = is_array($this->transitionBranchConfig[BehaviorType::Guard->value]) 132 | ? $this->transitionBranchConfig[BehaviorType::Guard->value] 133 | : [$this->transitionBranchConfig[BehaviorType::Guard->value]]; 134 | 135 | $this->initializeInlineBehaviors( 136 | inlineBehaviors: $this->guards, 137 | behaviorType: BehaviorType::Guard 138 | ); 139 | } 140 | } 141 | 142 | /** 143 | * Initializes the action/s for this transition. 144 | */ 145 | protected function initializeActions(): void 146 | { 147 | if (isset($this->transitionBranchConfig[BehaviorType::Action->value])) { 148 | $this->actions = is_array($this->transitionBranchConfig[BehaviorType::Action->value]) 149 | ? $this->transitionBranchConfig[BehaviorType::Action->value] 150 | : [$this->transitionBranchConfig[BehaviorType::Action->value]]; 151 | 152 | $this->initializeInlineBehaviors( 153 | inlineBehaviors: $this->actions, 154 | behaviorType: BehaviorType::Action 155 | ); 156 | } 157 | } 158 | 159 | /** 160 | * Adds inline behavior definitions to machine's behavior. 161 | * 162 | * @param array $inlineBehaviors An array of inline behaviors. 163 | * @param BehaviorType $behaviorType The type of behavior. 164 | */ 165 | protected function initializeInlineBehaviors(array $inlineBehaviors, BehaviorType $behaviorType): void 166 | { 167 | foreach ($inlineBehaviors as $behavior) { 168 | // If the behavior contains a colon, it means that it has a parameter. 169 | if (str_contains($behavior, ':')) { 170 | $behavior = explode(':', $behavior)[0]; 171 | } 172 | 173 | // If the behavior is class of a known behavior type (e.g., Guard, Action, etc.), 174 | // add it to the machine's behavior too. 175 | if (is_subclass_of($behavior, class: $behaviorType->getBehaviorClass())) { 176 | $this 177 | ->transitionDefinition 178 | ->source 179 | ->machine 180 | ->behavior[$behaviorType->value][$behavior::getType()] = $behavior; 181 | } 182 | } 183 | } 184 | 185 | /** 186 | * Execute the actions associated with transition definition. 187 | * 188 | * If there are no actions associated with the transition definition, do nothing. 189 | * 190 | * @param EventBehavior|null $eventBehavior The event or null if none is provided. 191 | * 192 | * @throws \ReflectionException 193 | */ 194 | public function runActions( 195 | State $state, 196 | ?EventBehavior $eventBehavior = null 197 | ): void { 198 | if ($this->actions === null) { 199 | return; 200 | } 201 | 202 | foreach ($this->actions as $actionDefinition) { 203 | $this->transitionDefinition->source->machine->runAction( 204 | $actionDefinition, 205 | $state, 206 | $eventBehavior 207 | ); 208 | } 209 | } 210 | 211 | // endregion 212 | } 213 | -------------------------------------------------------------------------------- /src/Commands/MachineConfigValidatorCommand.php: -------------------------------------------------------------------------------- 1 | parser = (new ParserFactory())->createForVersion(PhpVersion::getHostVersion()); 32 | $this->traverser = new NodeTraverser(); 33 | $this->visitor = new MachineClassVisitor(); 34 | $this->traverser->addVisitor($this->visitor); 35 | } 36 | 37 | public function handle(): void 38 | { 39 | if ($this->option(key: 'all')) { 40 | $this->validateAllMachines(); 41 | 42 | return; 43 | } 44 | 45 | $machines = $this->argument(key: 'machine'); 46 | if (empty($machines)) { 47 | $this->error(string: 'Please provide a machine class name or use --all option.'); 48 | 49 | return; 50 | } 51 | 52 | foreach ($machines as $machine) { 53 | $this->validateMachine($machine); 54 | } 55 | } 56 | 57 | protected function validateMachine(string $machineClass): void 58 | { 59 | try { 60 | $machines = $this->findMachineClasses(); 61 | /** @var Machine $fullClassName */ 62 | $fullClassName = $this->resolveFullClassName($machineClass, $machines); 63 | 64 | if (!$fullClassName) { 65 | $this->error(string: "Machine class '{$machineClass}' not found."); 66 | 67 | return; 68 | } 69 | 70 | $definition = $fullClassName::definition(); 71 | if ($definition === null) { 72 | $this->error(string: "Machine '{$fullClassName}' has no definition."); 73 | 74 | return; 75 | } 76 | 77 | StateConfigValidator::validate($definition->config); 78 | $this->info(string: "✓ Machine '{$fullClassName}' configuration is valid."); 79 | 80 | } catch (Throwable $e) { 81 | $this->error(string: "Error validating '{$machineClass}': ".$e->getMessage()); 82 | } 83 | } 84 | 85 | protected function findMachineClasses(): array 86 | { 87 | $searchPaths = $this->getSearchPaths(); 88 | $machines = []; 89 | 90 | $finder = new Finder(); 91 | $finder->files() 92 | ->name(patterns: '*.php') 93 | ->in($searchPaths); 94 | 95 | foreach ($finder as $file) { 96 | try { 97 | $code = $file->getContents(); 98 | $ast = $this->parser->parse($code); 99 | 100 | $this->visitor->setCurrentFile($file->getRealPath()); 101 | $this->traverser->traverse($ast); 102 | 103 | $machines[] = $this->visitor->getMachineClasses(); 104 | } catch (Throwable) { 105 | continue; 106 | } 107 | } 108 | 109 | return array_unique(array_merge(...$machines)); 110 | } 111 | 112 | protected function getSearchPaths(): array 113 | { 114 | $paths = $this->isInPackageDevelopment() 115 | ? $this->getPackageDevelopmentPaths() 116 | : $this->getProjectPaths(); 117 | 118 | if (empty($paths)) { 119 | throw new RuntimeException( 120 | message: 'No valid search paths found for Machine classes. '. 121 | 'If you are using event-machine package, please ensure your Machine classes are in the app/ directory.' 122 | ); 123 | } 124 | 125 | return array_filter($paths, callback: 'is_dir'); 126 | } 127 | 128 | protected function isInPackageDevelopment(): bool 129 | { 130 | return !str_contains($this->getPackageRootPath(), '/vendor/'); 131 | } 132 | 133 | protected function getPackageDevelopmentPaths(): array 134 | { 135 | $paths = []; 136 | $composerJson = $this->getComposerConfig(); 137 | 138 | if (!$composerJson) { 139 | return $paths; 140 | } 141 | 142 | // Add PSR-4 autoload paths 143 | foreach (['autoload', 'autoload-dev'] as $autoloadType) { 144 | if (!isset($composerJson[$autoloadType]['psr-4'])) { 145 | continue; 146 | } 147 | 148 | foreach ($composerJson[$autoloadType]['psr-4'] as $namespace => $path) { 149 | $namespacePaths = (array) $path; 150 | foreach ($namespacePaths as $namespacePath) { 151 | $absolutePath = $this->getPackageRootPath().'/'.trim($namespacePath, characters: '/'); 152 | if (is_dir($absolutePath)) { 153 | $paths[] = $absolutePath; 154 | } 155 | } 156 | } 157 | } 158 | 159 | return $paths; 160 | } 161 | 162 | protected function getProjectPaths(): array 163 | { 164 | $paths = []; 165 | 166 | // Project app directory 167 | $appPath = base_path('app'); 168 | if (is_dir($appPath)) { 169 | $paths[] = $appPath; 170 | } 171 | 172 | return $paths; 173 | } 174 | 175 | /** 176 | * @throws \JsonException 177 | */ 178 | protected function getComposerConfig(): ?array 179 | { 180 | $composerPath = $this->getPackageRootPath().'/composer.json'; 181 | 182 | if (!file_exists($composerPath)) { 183 | return null; 184 | } 185 | 186 | $content = file_get_contents($composerPath); 187 | if ($content === false) { 188 | return null; 189 | } 190 | 191 | $config = json_decode($content, associative: true, depth: JSON_THROW_ON_ERROR, flags: JSON_THROW_ON_ERROR); 192 | if (json_last_error() !== JSON_ERROR_NONE) { 193 | return null; 194 | } 195 | 196 | return $config; 197 | } 198 | 199 | protected function getPackageRootPath(): string 200 | { 201 | $reflection = new ReflectionClass(objectOrClass: Machine::class); 202 | 203 | return dirname($reflection->getFileName(), levels: 3); 204 | } 205 | 206 | protected function resolveFullClassName(string $shortName, array $machines): ?string 207 | { 208 | // If it's already a full class name 209 | if (in_array($shortName, $machines, strict: true)) { 210 | return $shortName; 211 | } 212 | 213 | // Try to find by class basename 214 | foreach ($machines as $machine) { 215 | if (class_basename($machine) === $shortName) { 216 | return $machine; 217 | } 218 | } 219 | 220 | return null; 221 | } 222 | 223 | protected function validateAllMachines(): void 224 | { 225 | $validated = 0; 226 | $failed = 0; 227 | 228 | $machines = $this->findMachineClasses(); 229 | 230 | foreach ($machines as $class) { 231 | try { 232 | $definition = $class::definition(); 233 | if ($definition === null) { 234 | $this->warn(string: "Machine '{$class}' has no definition."); 235 | $failed++; 236 | 237 | continue; 238 | } 239 | 240 | StateConfigValidator::validate($definition->config); 241 | $this->info(string: "✓ Machine '{$class}' configuration is valid."); 242 | $validated++; 243 | } catch (Throwable $e) { 244 | $this->error(string: "✗ Error in '{$class}': ".$e->getMessage()); 245 | $failed++; 246 | } 247 | } 248 | 249 | $this->newLine(); 250 | $this->info(string: "Validation complete: {$validated} valid, {$failed} failed"); 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /src/Definition/TransitionDefinition.php: -------------------------------------------------------------------------------- 1 | event === TransitionProperty::Always->value) { 55 | $this->isAlways = true; 56 | } 57 | 58 | $this->description = $this->transitionConfig['description'] ?? null; 59 | 60 | if ($this->transitionConfig === null) { 61 | $this->branches[] = new TransitionBranch( 62 | transitionBranchConfig: $this->transitionConfig, 63 | transitionDefinition: $this 64 | ); 65 | 66 | return; 67 | } 68 | 69 | if (is_string($this->transitionConfig)) { 70 | $this->branches[] = new TransitionBranch( 71 | transitionBranchConfig: $this->transitionConfig, 72 | transitionDefinition: $this 73 | ); 74 | 75 | return; 76 | } 77 | 78 | if ($this->isAMultiPathGuardedTransition($this->transitionConfig) === false) { 79 | $this->transitionConfig = [$this->transitionConfig]; 80 | } 81 | 82 | // If the transition has multiple branches, it is a guarded transition 83 | if (count($this->transitionConfig) > 1) { 84 | $this->isGuarded = true; 85 | } 86 | 87 | foreach ($this->transitionConfig as $config) { 88 | $this->branches[] = new TransitionBranch($config, $this); 89 | } 90 | } 91 | 92 | // endregion 93 | 94 | // region Protected Methods 95 | 96 | /** 97 | * Determines if the given transition configuration represents a multi-path guarded transition. 98 | * This method checks if the provided array has numeric keys and array values, indicating 99 | * that it contains multiple guarded transitions based on different guards. 100 | * 101 | * @param array|string|null $transitionConfig The transition configuration to examine. 102 | * 103 | * @return bool True if the configuration represents a multi-path guarded transition, false otherwise. 104 | */ 105 | protected function isAMultiPathGuardedTransition(null|array|string $transitionConfig): bool 106 | { 107 | if (is_null($transitionConfig) || is_string($transitionConfig) || $transitionConfig === []) { 108 | return false; 109 | } 110 | 111 | // Iterate through the input array 112 | foreach ($transitionConfig as $key => $value) { 113 | // Check if the key is numeric and the value is an array 114 | if (!is_int($key) || !is_array($value)) { 115 | return false; 116 | } 117 | } 118 | 119 | return true; 120 | } 121 | 122 | // endregion 123 | 124 | // region Public Methods 125 | 126 | /** 127 | * Selects the first eligible transition while evaluating guards. 128 | * 129 | * This method iterates through the given transition candidates and 130 | * checks if all the guards are passed. If a candidate transition 131 | * does not have any guards, it is considered eligible. 132 | * If a transition with guards has all its guards evaluated 133 | * to true, it is considered eligible. The method returns the first 134 | * eligible transition encountered or null if none is found. 135 | * 136 | * @param EventBehavior $eventBehavior The event used to evaluate guards. 137 | * 138 | * @return TransitionDefinition|null The first eligible transition or 139 | * null if no eligible transition is found. 140 | * 141 | * @throws \ReflectionException 142 | */ 143 | public function getFirstValidTransitionBranch( 144 | EventBehavior $eventBehavior, 145 | State $state 146 | ): ?TransitionBranch { 147 | /* @var TransitionBranch $branch */ 148 | foreach ($this->branches as $branch) { 149 | if ($this->runCalculators($state, $eventBehavior, $branch) === false) { 150 | return null; 151 | } 152 | 153 | if (!isset($branch->guards)) { 154 | return $branch; 155 | } 156 | 157 | $guardsPassed = true; 158 | foreach ($branch->guards as $guardDefinition) { 159 | [$guardDefinition, $guardArguments] = array_pad(explode(':', $guardDefinition, 2), 2, null); 160 | $guardArguments = $guardArguments === null ? [] : explode(',', $guardArguments); 161 | 162 | $guardBehavior = $this->source->machine->getInvokableBehavior( 163 | behaviorDefinition: $guardDefinition, 164 | behaviorType: BehaviorType::Guard 165 | ); 166 | 167 | $shouldLog = $guardBehavior?->shouldLog ?? false; 168 | 169 | if ($guardBehavior instanceof GuardBehavior) { 170 | $guardBehavior::validateRequiredContext($state->context); 171 | } 172 | 173 | // Inject guard behavior parameters 174 | $guardBehaviorParameters = InvokableBehavior::injectInvokableBehaviorParameters( 175 | actionBehavior: $guardBehavior, 176 | state: $state, 177 | eventBehavior: $eventBehavior, 178 | actionArguments: $guardArguments, 179 | ); 180 | 181 | // Execute the guard behavior 182 | $guardResult = ($guardBehavior)(...$guardBehaviorParameters); 183 | 184 | if ($guardResult === false) { 185 | $guardsPassed = false; 186 | 187 | $payload = null; 188 | if ($guardBehavior instanceof ValidationGuardBehavior) { 189 | $errorMessage = $guardBehavior->errorMessage; 190 | $errorKey = InternalEvent::GUARD_FAIL->generateInternalEventName( 191 | machineId: $this->source->machine->id, 192 | placeholder: $guardBehavior::getType() 193 | ); 194 | 195 | $payload = [$errorKey => $errorMessage]; 196 | } 197 | 198 | // Record the internal guard fail event. 199 | $state->setInternalEventBehavior( 200 | type: InternalEvent::GUARD_FAIL, 201 | placeholder: $guardDefinition, 202 | payload: $payload, 203 | shouldLog: $shouldLog, 204 | ); 205 | 206 | break; 207 | } 208 | 209 | // Record the internal guard pass event. 210 | $state->setInternalEventBehavior( 211 | type: InternalEvent::GUARD_PASS, 212 | placeholder: $guardDefinition, 213 | shouldLog: $shouldLog, 214 | ); 215 | } 216 | 217 | if ($guardsPassed === true) { 218 | return $branch; 219 | } 220 | } 221 | 222 | return null; 223 | } 224 | 225 | /** 226 | * Executes calculator behaviors associated with this transition. 227 | * 228 | * Returns false if any calculator fails, preventing the transition. 229 | */ 230 | public function runCalculators( 231 | State $state, 232 | EventBehavior $eventBehavior, 233 | TransitionBranch $branch, 234 | ): bool { 235 | if (!isset($branch->calculators)) { 236 | return true; 237 | } 238 | 239 | foreach ($branch->calculators as $calculatorDefinition) { 240 | [$calculatorDefinition, $calculatorArguments] = array_pad(explode(':', $calculatorDefinition, 2), 2, null); 241 | $calculatorArguments = $calculatorArguments === null ? [] : explode(',', $calculatorArguments); 242 | 243 | $calculatorBehavior = $this->source->machine->getInvokableBehavior( 244 | behaviorDefinition: $calculatorDefinition, 245 | behaviorType: BehaviorType::Calculator 246 | ); 247 | 248 | $shouldLog = $calculatorBehavior->shouldLog ?? false; 249 | 250 | try { 251 | $calculatorParameters = InvokableBehavior::injectInvokableBehaviorParameters( 252 | actionBehavior: $calculatorBehavior, 253 | state: $state, 254 | eventBehavior: $eventBehavior, 255 | actionArguments: $calculatorArguments, 256 | ); 257 | 258 | ($calculatorBehavior)(...$calculatorParameters); 259 | 260 | $state->setInternalEventBehavior( 261 | type: InternalEvent::CALCULATOR_PASS, 262 | placeholder: $calculatorDefinition, 263 | shouldLog: $shouldLog 264 | ); 265 | } catch (Throwable $e) { 266 | $state->setInternalEventBehavior( 267 | type: InternalEvent::CALCULATOR_FAIL, 268 | placeholder: $calculatorDefinition, 269 | payload: [ 270 | 'error' => "Calculator failed: {$e->getMessage()}", 271 | ], 272 | shouldLog: $shouldLog 273 | ); 274 | 275 | return false; 276 | } 277 | } 278 | 279 | return true; 280 | } 281 | 282 | // endregion 283 | } 284 | -------------------------------------------------------------------------------- /src/StateConfigValidator.php: -------------------------------------------------------------------------------- 1 | $stateConfig) { 56 | self::validateStateConfig($stateConfig, $stateName); 57 | } 58 | } 59 | 60 | // Validate root level transitions if they exist 61 | if (isset($config['on'])) { 62 | self::validateTransitionsConfig( 63 | transitionsConfig: $config['on'], 64 | path: 'root', 65 | parentState: $config 66 | ); 67 | } 68 | } 69 | 70 | /** 71 | * Validates root level configuration. 72 | * 73 | * @throws InvalidArgumentException 74 | */ 75 | private static function validateRootConfig(array $config): void 76 | { 77 | $invalidRootKeys = array_diff( 78 | array_keys($config), 79 | self::ALLOWED_ROOT_KEYS 80 | ); 81 | 82 | if (!empty($invalidRootKeys)) { 83 | throw new InvalidArgumentException( 84 | message: 'Invalid root level configuration keys: '.implode(separator: ', ', array: $invalidRootKeys). 85 | '. Allowed keys are: '.implode(separator: ', ', array: self::ALLOWED_ROOT_KEYS) 86 | ); 87 | } 88 | } 89 | 90 | /** 91 | * Validates a single state's configuration. 92 | * 93 | * This method performs comprehensive validation of a state's configuration including: 94 | * - Checking for directly defined transitions 95 | * - Validating state keys 96 | * - Validating state type 97 | * - Validating entry/exit actions 98 | * - Processing nested states 99 | * - Validating transitions 100 | * 101 | * The validation order is important: we validate nested states first, 102 | * then process the transitions to ensure proper context. 103 | * 104 | * @throws InvalidArgumentException When state configuration is invalid 105 | */ 106 | private static function validateStateConfig(?array $stateConfig, string $path): void 107 | { 108 | if ($stateConfig === null) { 109 | return; 110 | } 111 | 112 | // Check for transitions defined outside 'on' 113 | if (isset($stateConfig['@always']) || array_key_exists(key: '@always', array: $stateConfig)) { 114 | throw new InvalidArgumentException( 115 | message: "State '{$path}' has transitions defined directly. ". 116 | "All transitions including '@always' must be defined under the 'on' key." 117 | ); 118 | } 119 | 120 | // Validate state keys 121 | $invalidKeys = array_diff(array_keys($stateConfig), self::ALLOWED_STATE_KEYS); 122 | if (!empty($invalidKeys)) { 123 | throw new InvalidArgumentException( 124 | message: "State '{$path}' has invalid keys: ".implode(separator: ', ', array: $invalidKeys). 125 | '. Allowed keys are: '.implode(separator: ', ', array: self::ALLOWED_STATE_KEYS) 126 | ); 127 | } 128 | 129 | // Validate state type if specified 130 | if (isset($stateConfig['type'])) { 131 | self::validateStateType(stateConfig: $stateConfig, path: $path); 132 | } 133 | 134 | // Validate entry/exit actions 135 | self::validateStateActions(stateConfig: $stateConfig, path: $path); 136 | 137 | // Final state validations 138 | if (isset($stateConfig['type']) && $stateConfig['type'] === 'final') { 139 | self::validateFinalState(stateConfig: $stateConfig, path: $path); 140 | } 141 | 142 | // Process nested states first to ensure proper context 143 | if (isset($stateConfig['states'])) { 144 | if (!is_array($stateConfig['states'])) { 145 | throw new InvalidArgumentException( 146 | message: "State '{$path}' has invalid states configuration. States must be an array." 147 | ); 148 | } 149 | 150 | foreach ($stateConfig['states'] as $childKey => $childState) { 151 | self::validateStateConfig( 152 | stateConfig: $childState, 153 | path: "{$path}.{$childKey}" 154 | ); 155 | } 156 | } 157 | 158 | // Validate transitions after processing nested states 159 | if (isset($stateConfig['on'])) { 160 | self::validateTransitionsConfig( 161 | transitionsConfig: $stateConfig['on'], 162 | path: $path, 163 | parentState: $stateConfig 164 | ); 165 | } 166 | } 167 | 168 | /** 169 | * Validates state type configuration. 170 | * 171 | * @throws InvalidArgumentException 172 | */ 173 | private static function validateStateType(array $stateConfig, string $path): void 174 | { 175 | if (!in_array($stateConfig['type'], haystack: self::VALID_STATE_TYPES, strict: true)) { 176 | throw new InvalidArgumentException( 177 | message: "State '{$path}' has invalid type: {$stateConfig['type']}. ". 178 | 'Allowed types are: '.implode(separator: ', ', array: self::VALID_STATE_TYPES) 179 | ); 180 | } 181 | } 182 | 183 | /** 184 | * Validates final state constraints. 185 | * 186 | * @throws InvalidArgumentException 187 | */ 188 | private static function validateFinalState(array $stateConfig, string $path): void 189 | { 190 | if (isset($stateConfig['on'])) { 191 | throw new InvalidArgumentException( 192 | message: "Final state '{$path}' cannot have transitions" 193 | ); 194 | } 195 | 196 | if (isset($stateConfig['states'])) { 197 | throw new InvalidArgumentException( 198 | message: "Final state '{$path}' cannot have child states" 199 | ); 200 | } 201 | } 202 | 203 | /** 204 | * Validates state entry and exit actions. 205 | * 206 | * @throws InvalidArgumentException 207 | */ 208 | private static function validateStateActions(array $stateConfig, string $path): void 209 | { 210 | foreach (['entry', 'exit'] as $actionType) { 211 | if (isset($stateConfig[$actionType])) { 212 | $actions = $stateConfig[$actionType]; 213 | if (!is_string($actions) && !is_array($actions)) { 214 | throw new InvalidArgumentException( 215 | message: "State '{$path}' has invalid entry/exit actions configuration. ". 216 | 'Actions must be an array or string.' 217 | ); 218 | } 219 | } 220 | } 221 | } 222 | 223 | /** 224 | * Validates the transitions configuration for a state. 225 | * 226 | * This method processes both standard event names and Event class names as transition triggers. 227 | * It ensures that all transitions are properly formatted and contain valid configuration. 228 | * 229 | * @throws InvalidArgumentException When transitions configuration is invalid 230 | */ 231 | private static function validateTransitionsConfig( 232 | mixed $transitionsConfig, 233 | string $path, 234 | ?array $parentState = null 235 | ): void { 236 | if (!is_array($transitionsConfig)) { 237 | throw new InvalidArgumentException( 238 | message: "State '{$path}' has invalid 'on' definition. 'on' must be an array of transitions." 239 | ); 240 | } 241 | 242 | foreach ($transitionsConfig as $eventName => $transition) { 243 | // Handle both Event classes and string event names 244 | if (is_string($eventName) && class_exists($eventName) && is_subclass_of($eventName, EventBehavior::class)) { 245 | self::validateTransition( 246 | transition: $transition, 247 | path: $path, 248 | eventName: $eventName::getType() 249 | ); 250 | 251 | continue; 252 | } 253 | 254 | self::validateTransition( 255 | transition: $transition, 256 | path: $path, 257 | eventName: $eventName 258 | ); 259 | } 260 | } 261 | 262 | /** 263 | * Validates a single transition configuration. 264 | */ 265 | private static function validateTransition( 266 | mixed $transition, 267 | string $path, 268 | string $eventName 269 | ): void { 270 | if ($transition === null) { 271 | return; 272 | } 273 | 274 | if (is_string($transition)) { 275 | return; 276 | } 277 | 278 | if (!is_array($transition)) { 279 | throw new InvalidArgumentException( 280 | message: "State '{$path}' has invalid transition for event '{$eventName}'. ". 281 | 'Transition must be a string (target state) or an array (transition config).' 282 | ); 283 | } 284 | 285 | // If it's an array of conditions (guarded transitions) 286 | if (array_is_list($transition)) { 287 | self::validateGuardedTransitions($transition, $path, $eventName); 288 | foreach ($transition as &$condition) { 289 | self::validateTransitionConfig(transitionConfig: $condition, path: $path, eventName: $eventName); 290 | } 291 | 292 | return; 293 | } 294 | 295 | self::validateTransitionConfig(transitionConfig: $transition, path: $path, eventName: $eventName); 296 | } 297 | 298 | /** 299 | * Validates the configuration of a single transition. 300 | */ 301 | private static function validateTransitionConfig( 302 | array &$transitionConfig, 303 | string $path, 304 | string $eventName 305 | ): void { 306 | // Validate allowed keys 307 | $invalidKeys = array_diff(array_keys($transitionConfig), self::ALLOWED_TRANSITION_KEYS); 308 | if (!empty($invalidKeys)) { 309 | throw new InvalidArgumentException( 310 | message: "State '{$path}' has invalid keys in transition config for event '{$eventName}': ". 311 | implode(separator: ', ', array: $invalidKeys). 312 | '. Allowed keys are: '.implode(separator: ', ', array: self::ALLOWED_TRANSITION_KEYS) 313 | ); 314 | } 315 | 316 | // Normalize and validate behaviors 317 | self::validateTransitionBehaviors(transitionConfig: $transitionConfig, path: $path, eventName: $eventName); 318 | } 319 | 320 | /** 321 | * Validates and normalizes transition behaviors (guards, actions, calculators). 322 | */ 323 | private static function validateTransitionBehaviors( 324 | array &$transitionConfig, 325 | string $path, 326 | string $eventName 327 | ): void { 328 | $behaviors = [ 329 | 'guards' => 'Guards', 330 | 'actions' => 'Actions', 331 | 'calculators' => 'Calculators', 332 | ]; 333 | 334 | foreach ($behaviors as $behavior => $label) { 335 | if (isset($transitionConfig[$behavior])) { 336 | try { 337 | $transitionConfig[$behavior] = self::normalizeArrayOrString(value: $transitionConfig[$behavior]); 338 | } catch (InvalidArgumentException) { 339 | throw new InvalidArgumentException( 340 | message: "State '{$path}' has invalid {$behavior} configuration for event '{$eventName}'. ". 341 | "{$label} must be an array or string." 342 | ); 343 | } 344 | } 345 | } 346 | } 347 | 348 | /** 349 | * Validates guarded transitions with multiple conditions. 350 | */ 351 | private static function validateGuardedTransitions(array $conditions, string $path, string $eventName): void 352 | { 353 | if (empty($conditions)) { 354 | throw new InvalidArgumentException( 355 | message: "State '{$path}' has empty conditions array for event '{$eventName}'. ". 356 | 'Guarded transitions must have at least one condition.' 357 | ); 358 | } 359 | 360 | foreach ($conditions as $index => $condition) { 361 | if (!is_array($condition)) { 362 | throw new InvalidArgumentException( 363 | message: "State '{$path}' has invalid condition in transition for event '{$eventName}'. ". 364 | 'Each condition must be an array with target/guards/actions.' 365 | ); 366 | } 367 | 368 | // If this is not the last condition and it has no guards 369 | if (!isset($condition['guards']) && $index !== count($conditions) - 1) { 370 | throw new InvalidArgumentException( 371 | message: "State '{$path}' has invalid conditions order for event '{$eventName}'. ". 372 | 'Default condition (no guards) must be the last condition.' 373 | ); 374 | } 375 | } 376 | } 377 | 378 | /** 379 | * Normalizes the given value into an array or returns null. 380 | * 381 | * @throws InvalidArgumentException If the value is neither string, array, nor null. 382 | */ 383 | private static function normalizeArrayOrString(mixed $value): ?array 384 | { 385 | if ($value === null) { 386 | return null; 387 | } 388 | 389 | if (is_string($value)) { 390 | return [$value]; 391 | } 392 | 393 | if (is_array($value)) { 394 | return $value; 395 | } 396 | 397 | throw new InvalidArgumentException('Value must be string, array or null'); 398 | } 399 | } 400 | -------------------------------------------------------------------------------- /src/Definition/StateDefinition.php: -------------------------------------------------------------------------------- 1 | 34 | */ 35 | public array $path; 36 | 37 | /** The string route from the root machine definition to this state definition. */ 38 | public string $route; 39 | 40 | /** The description of the state definition. */ 41 | public ?string $description; 42 | 43 | /** The order this state definition appears. */ 44 | public int $order = -1; 45 | 46 | /** 47 | * The child state definitions of this state definition. 48 | * 49 | * @var null|array 50 | */ 51 | public ?array $stateDefinitions = null; 52 | 53 | /** The type of this state definition. */ 54 | public StateDefinitionType $type; 55 | 56 | /** 57 | * The transition definitions of this state definition. 58 | * 59 | * @var null|array<\Tarfinlabs\EventMachine\Definition\TransitionDefinition> 60 | */ 61 | public ?array $transitionDefinitions; 62 | 63 | /** 64 | * The events that can be accepted by this state definition. 65 | * 66 | * @var null|array 67 | */ 68 | public ?array $events = null; 69 | 70 | /** The initial state definition for this machine definition. */ 71 | public ?StateDefinition $initialStateDefinition = null; 72 | 73 | /** 74 | * The action(s) to be executed upon entering the state definition. 75 | * 76 | * @var null|array 77 | */ 78 | public ?array $entry = []; 79 | 80 | /** 81 | * The action(s) to be executed upon exiting the state definition. 82 | * 83 | * @var null|array 84 | */ 85 | public ?array $exit = []; 86 | 87 | /** 88 | * The meta data associated with this state definition, 89 | * which will be returned in {@see \Tarfinlabs\EventMachine\Actor\State} instances. 90 | * 91 | * @var null|array 92 | */ 93 | public ?array $meta = null; 94 | 95 | // endregion 96 | 97 | // region Constructor 98 | 99 | /** 100 | * Create a new state definition with the given configuration and options. 101 | * 102 | * @param ?array $config The raw configuration array used to create the state definition. 103 | * @param ?array $options The `options` array for configuring the state definition. 104 | */ 105 | public function __construct( 106 | public ?array $config, 107 | ?array $options = null, 108 | ) { 109 | $this->initializeOptions($options); 110 | 111 | $this->path = $this->buildPath(); 112 | $this->route = $this->buildRoute(); 113 | $this->id = $this->buildId(); 114 | $this->description = $this->buildDescription(); 115 | 116 | $this->order = count($this->machine->idMap); 117 | 118 | $this->machine->idMap[$this->id] = $this; 119 | 120 | $this->stateDefinitions = $this->createChildStateDefinitions(); 121 | $this->type = $this->getStateDefinitionType(); 122 | 123 | if ($this->type === StateDefinitionType::FINAL) { 124 | $this->initializeResults(); 125 | } 126 | 127 | $this->events = $this->collectUniqueEvents(); 128 | 129 | $this->initialStateDefinition = $this->findInitialStateDefinition(); 130 | 131 | $this->initializeEntryActions(); 132 | $this->initializeExitActions(); 133 | 134 | $this->meta = $this->config['meta'] ?? null; 135 | } 136 | 137 | // endregion 138 | 139 | // region Protected Methods 140 | 141 | /** 142 | * Initialize the path for this state definition by appending its key to the parent's path. 143 | * 144 | * @return array The path for this state definition. 145 | */ 146 | protected function buildPath(): array 147 | { 148 | return $this->parent 149 | ? array_merge($this->parent->path, [$this->key]) 150 | : []; 151 | } 152 | 153 | /** 154 | * Build the route by concatenating the path elements with the delimiter. 155 | * 156 | * @return string The built route as a string. 157 | */ 158 | protected function buildRoute(): string 159 | { 160 | return implode($this->machine->delimiter, $this->path); 161 | } 162 | 163 | /** 164 | * Initialize id for this state definition by concatenating 165 | * the machine id, path, and delimiter. 166 | * 167 | * @return string The global id for this state definition. 168 | */ 169 | protected function buildId(): string 170 | { 171 | return $this->config['id'] ?? implode($this->machine->delimiter, array_merge([$this->machine->id], $this->path)); 172 | } 173 | 174 | /** 175 | * Initialize the description for this state definition. 176 | */ 177 | protected function buildDescription(): ?string 178 | { 179 | return $this->config['description'] ?? null; 180 | } 181 | 182 | /** 183 | * Initialize the child state definitions for this state definition by iterating through 184 | * the 'states' configuration and creating new StateDefinition instances. 185 | * 186 | * @return ?array An array of child state definitions or null if no child states are defined. 187 | */ 188 | protected function createChildStateDefinitions(): ?array 189 | { 190 | if (!isset($this->config['states']) || !is_array($this->config['states'])) { 191 | return null; 192 | } 193 | 194 | $states = []; 195 | foreach ($this->config['states'] as $stateName => $stateConfig) { 196 | $states[$stateName] = new StateDefinition( 197 | config: $stateConfig, 198 | options: [ 199 | 'parent' => $this, 200 | 'machine' => $this->machine, 201 | 'key' => $stateName, 202 | ] 203 | ); 204 | } 205 | 206 | return $states; 207 | } 208 | 209 | /** 210 | * Initialize the options for this state definition. 211 | */ 212 | protected function initializeOptions(?array $options): void 213 | { 214 | $this->parent = $options['parent'] ?? null; 215 | $this->machine = $options['machine'] ?? null; 216 | $this->key = $options['key'] ?? null; 217 | } 218 | 219 | /** 220 | * Initialize the results for the current state. 221 | * 222 | * If a result is set in the configuration, it will be assigned to the machine's behavior. 223 | */ 224 | protected function initializeResults(): void 225 | { 226 | if (isset($this->config['result'])) { 227 | $this->machine->behavior[BehaviorType::Result->value][$this->id] = $this->config['result']; 228 | } 229 | } 230 | 231 | /** 232 | * Create transition definitions for a given state definition. 233 | * 234 | * This method processes the 'on' configuration of the state definition, creating 235 | * corresponding {@see \Tarfinlabs\EventMachine\Definition\TransitionDefinition} objects for 236 | * each event. 237 | * 238 | * @param StateDefinition $stateDefinition The state definition to process. 239 | * 240 | * @return array|null An array of TransitionDefinition objects, keyed by event names. 241 | */ 242 | protected function createTransitionDefinitions(StateDefinition $stateDefinition): ?array 243 | { 244 | /** @var null|array $transitions */ 245 | $transitions = null; 246 | 247 | if ( 248 | !isset($stateDefinition->config['on']) || 249 | !is_array($stateDefinition->config['on']) 250 | ) { 251 | return $transitions; 252 | } 253 | 254 | foreach ($stateDefinition->config['on'] as $eventName => $transitionConfig) { 255 | if (is_subclass_of($eventName, EventBehavior::class)) { 256 | $this->machine->behavior[BehaviorType::Event->value][$eventName::getType()] = $eventName; 257 | 258 | $eventName = $eventName::getType(); 259 | } 260 | 261 | $transitions[$eventName] = new TransitionDefinition( 262 | transitionConfig: $transitionConfig, 263 | source: $this, 264 | event: $eventName, 265 | ); 266 | } 267 | 268 | return $transitions; 269 | } 270 | 271 | /** 272 | * Finds the initial `StateDefinition` based on the `initial` 273 | * configuration key or the first state definition found. 274 | * 275 | * @return StateDefinition|null The `StateDefinition` object for the initial state or `null` if not found. 276 | */ 277 | public function findInitialStateDefinition(): ?StateDefinition 278 | { 279 | // Try to find the initial state definition key in the configuration. 280 | // If not found, try to find the first state definition key. 281 | $initialStateDefinitionKey = $this->config['initial'] 282 | ?? array_key_first($this->stateDefinitions ?? []) 283 | ?? null; 284 | 285 | // If there is no initial state definition key, then this root state definition is the initial state. 286 | if ($initialStateDefinitionKey === null) { 287 | return $this->order === 0 ? $this : null; 288 | } 289 | 290 | // The initial state definition key is built by concatenating the root state definition id 291 | $initialStateDefinitionKey = $this->id.$this->machine->delimiter.$initialStateDefinitionKey; 292 | 293 | // Try to find the initial state definition in the machine's id map. 294 | /** @var \Tarfinlabs\EventMachine\Definition\StateDefinition $initialStateDefinition */ 295 | $initialStateDefinition = $this->machine->idMap[$initialStateDefinitionKey] ?? null; 296 | 297 | if ($initialStateDefinition === null) { 298 | return null; 299 | } 300 | 301 | // If the initial state definition has child state definitions, 302 | // then try to find it recursively from child state definitions. 303 | return ( 304 | is_array($initialStateDefinition->stateDefinitions) && 305 | count($initialStateDefinition->stateDefinitions) > 0 306 | ) 307 | ? $initialStateDefinition->findInitialStateDefinition() 308 | : $initialStateDefinition; 309 | } 310 | 311 | /** 312 | * Initialize the entry action/s for this state definition. 313 | */ 314 | protected function initializeEntryActions(): void 315 | { 316 | if (isset($this->config['entry'])) { 317 | $this->entry = is_array($this->config['entry']) 318 | ? $this->config['entry'] 319 | : [$this->config['entry']]; 320 | } else { 321 | $this->entry = []; 322 | } 323 | } 324 | 325 | /** 326 | * Initialize the exit action/s for this state definition. 327 | */ 328 | protected function initializeExitActions(): void 329 | { 330 | if (isset($this->config['exit'])) { 331 | $this->exit = is_array($this->config['exit']) 332 | ? $this->config['exit'] 333 | : [$this->config['exit']]; 334 | } else { 335 | $this->exit = []; 336 | } 337 | } 338 | 339 | /** 340 | * Get the type of the state definition. 341 | * 342 | * @return StateDefinitionType The type of the state definition. 343 | */ 344 | public function getStateDefinitionType(): StateDefinitionType 345 | { 346 | if (!empty($this->config['type']) && $this->config['type'] === 'final') { 347 | if ($this->stateDefinitions !== null) { 348 | throw InvalidFinalStateDefinitionException::noChildStates($this->id); 349 | } 350 | 351 | return StateDefinitionType::FINAL; 352 | } 353 | 354 | if ($this->stateDefinitions === null) { 355 | return StateDefinitionType::ATOMIC; 356 | } 357 | 358 | return StateDefinitionType::COMPOUND; 359 | } 360 | 361 | // endregion 362 | 363 | // region Public Methods 364 | 365 | /** 366 | * Initialize the transitions for the current state and its child states. 367 | */ 368 | public function initializeTransitions(): void 369 | { 370 | $this->transitionDefinitions = $this->createTransitionDefinitions($this); 371 | 372 | if ($this->stateDefinitions !== null) { 373 | /** @var StateDefinition $state */ 374 | foreach ($this->stateDefinitions as $state) { 375 | $state->initializeTransitions(); 376 | } 377 | } 378 | } 379 | 380 | /** 381 | * Initialize and return the events for the current state and its child states. 382 | * This method ensures that each event name is unique. 383 | * 384 | * @return array|null An array of unique event names. 385 | */ 386 | public function collectUniqueEvents(): ?array 387 | { 388 | // Initialize an empty array to store unique event names 389 | $events = []; 390 | 391 | // If there are transitions defined for the current state definition, 392 | // add the event names to the events array. 393 | if (isset($this->config['on']) && is_array($this->config['on'])) { 394 | foreach ($this->config['on'] as $eventName => $transitionConfig) { 395 | if (is_subclass_of($eventName, EventBehavior::class)) { 396 | $eventName = $eventName::getType(); 397 | } 398 | 399 | // Only add the event name if it hasn't been added yet 400 | if (!in_array($eventName, $events, true)) { 401 | $events[] = $eventName; 402 | } 403 | } 404 | } 405 | 406 | // If there are child states, process them recursively and 407 | // add their event names to the events array. 408 | if ($this->stateDefinitions !== null) { 409 | /** @var StateDefinition $state */ 410 | foreach ($this->stateDefinitions as $state) { 411 | // Get the events from the child state definition. 412 | $childEvents = $state->collectUniqueEvents(); 413 | 414 | // Add the events from the child state to the events array, ensuring uniqueness 415 | if ($childEvents !== null) { 416 | foreach ($childEvents as $eventName) { 417 | if (!in_array($eventName, $events, true)) { 418 | $events[] = $eventName; 419 | } 420 | } 421 | } 422 | } 423 | } 424 | 425 | // Return the array of unique event names 426 | return $events === [] ? null : $events; 427 | } 428 | 429 | /** 430 | * Runs the exit actions of the current state definition with the given event. 431 | */ 432 | public function runExitActions(State $state): void 433 | { 434 | // Record state exit start event 435 | $state->setInternalEventBehavior( 436 | type: InternalEvent::STATE_EXIT_START, 437 | placeholder: $state->currentStateDefinition->route, 438 | ); 439 | 440 | foreach ($this->exit as $action) { 441 | $this->machine->runAction( 442 | actionDefinition: $action, 443 | state: $state, 444 | eventBehavior: $state->currentEventBehavior 445 | ); 446 | } 447 | 448 | // Record state exit finish event 449 | $state->setInternalEventBehavior( 450 | type: InternalEvent::STATE_EXIT_FINISH, 451 | placeholder: $state->currentStateDefinition->route, 452 | ); 453 | } 454 | 455 | /** 456 | * Runs the entry actions of the current state definition with the given event. 457 | * 458 | * @param \Tarfinlabs\EventMachine\Behavior\EventBehavior|null $eventBehavior The event to be processed. 459 | */ 460 | public function runEntryActions(State $state, ?EventBehavior $eventBehavior = null): void 461 | { 462 | // Record state entry start event 463 | $state->setInternalEventBehavior( 464 | type: InternalEvent::STATE_ENTRY_START, 465 | placeholder: $state->currentStateDefinition->route, 466 | ); 467 | 468 | foreach ($this->entry as $action) { 469 | $this->machine->runAction( 470 | actionDefinition: $action, 471 | state: $state, 472 | eventBehavior: $eventBehavior 473 | ); 474 | } 475 | 476 | // Record state entry start event 477 | $state->setInternalEventBehavior( 478 | type: InternalEvent::STATE_ENTRY_FINISH, 479 | placeholder: $state->currentStateDefinition->route, 480 | ); 481 | } 482 | 483 | // endregion 484 | } 485 | -------------------------------------------------------------------------------- /src/Actor/Machine.php: -------------------------------------------------------------------------------- 1 | definition = $definition; 59 | } 60 | 61 | /** 62 | * Creates a new machine instance with the given definition. 63 | * 64 | * This method provides a way to initialize a machine using a specific 65 | * `MachineDefinition`. It returns a new instance of the `Machine` class, 66 | * encapsulating the provided definition. 67 | * 68 | * @param MachineDefinition $definition The definition to initialize the machine with. 69 | * 70 | * @return self The newly created machine instance. 71 | */ 72 | public static function withDefinition(MachineDefinition $definition): self 73 | { 74 | return new self($definition); 75 | } 76 | 77 | // endregion 78 | 79 | // region Machine Definition 80 | 81 | /** 82 | * Retrieves the machine definition. 83 | * 84 | * This method retrieves the machine definition. If the definition is not 85 | * found, it throws a `MachineDefinitionNotFoundException`. 86 | * 87 | * @return MachineDefinition|null The machine definition, or null if not found. 88 | * 89 | * @throws MachineDefinitionNotFoundException If the machine definition is not found. 90 | */ 91 | public static function definition(): ?MachineDefinition 92 | { 93 | throw MachineDefinitionNotFoundException::build(); 94 | } 95 | 96 | // endregion 97 | 98 | // region Event Handling 99 | 100 | /** 101 | * Creates and initializes a new machine instance. 102 | * 103 | * This method constructs a new machine instance, initializing it with the 104 | * provided definition and state. If the definition is `null`, it attempts 105 | * to retrieve the definition using the `definition()` method. 106 | * 107 | * @param \Tarfinlabs\EventMachine\Definition\MachineDefinition|array|null $definition The definition to initialize the machine with. 108 | * @param \Tarfinlabs\EventMachine\Actor\State|string|null $state The initial state of the machine. 109 | * 110 | * @return self The newly created and initialized machine instance. 111 | */ 112 | public static function create( 113 | MachineDefinition|array|null $definition = null, 114 | State|string|null $state = null, 115 | ): self { 116 | if (is_array($definition)) { 117 | $definition = MachineDefinition::define( 118 | config: $definition['config'] ?? null, 119 | behavior: $definition['behavior'] ?? null, 120 | ); 121 | } 122 | 123 | $machine = new self(definition: $definition ?? static::definition()); 124 | 125 | $machine->start($state); 126 | 127 | return $machine; 128 | } 129 | 130 | /** 131 | * Starts the machine with the specified state. 132 | * 133 | * This method starts the machine with the given state. If no state is provided, 134 | * it uses the machine's initial state. If a string is provided, it restores 135 | * the state using the `restoreStateFromRootEventId()` method. 136 | * 137 | * @param \Tarfinlabs\EventMachine\Actor\State|string|null $state The initial state or root event identifier. 138 | * 139 | * @return self The started machine instance. 140 | */ 141 | public function start(State|string|null $state = null): self 142 | { 143 | $this->state = match (true) { 144 | $state === null => $this->definition->getInitialState(), 145 | $state instanceof State => $state, 146 | is_string($state) => $this->restoreStateFromRootEventId($state), 147 | }; 148 | 149 | return $this; 150 | } 151 | 152 | /** 153 | * Sends an event to the machine and updates its state. 154 | * 155 | * This method transitions the machine's state based on the given event. It 156 | * updates the machine's state and handles validation guards. If the event 157 | * should be persisted, it calls the `persist()` method. 158 | * 159 | * @param \Tarfinlabs\EventMachine\Behavior\EventBehavior|array|string $event The event to send to the machine. 160 | * 161 | * @return State The updated state of the machine. 162 | * 163 | * @throws Exception 164 | */ 165 | public function send( 166 | EventBehavior|array|string $event, 167 | ): State { 168 | if ($this->state !== null) { 169 | $lock = Cache::lock('mre:'.$this->state->history->first()->root_event_id, 60); 170 | } 171 | 172 | if (isset($lock) && !$lock->get()) { 173 | throw MachineAlreadyRunningException::build($this->state->history->first()->root_event_id); 174 | } 175 | 176 | try { 177 | $lastPreviousEventNumber = $this->state !== null 178 | ? $this->state->history->last()->sequence_number 179 | : 0; 180 | 181 | // If the event is a string, we assume it's the event type. 182 | if (is_string($event)) { 183 | $event = ['type' => $event]; 184 | } 185 | 186 | $this->state = match (true) { 187 | $event->isTransactional ?? false => DB::transaction(fn () => $this->definition->transition($event, $this->state)), 188 | default => $this->definition->transition($event, $this->state) 189 | }; 190 | 191 | if ($this->definition->shouldPersist) { 192 | $this->persist(); 193 | } 194 | 195 | $this->handleValidationGuards($lastPreviousEventNumber); 196 | } catch (Exception $exception) { 197 | throw $exception; 198 | } finally { 199 | if (isset($lock)) { 200 | $lock->release(); 201 | } 202 | } 203 | 204 | return $this->state; 205 | } 206 | 207 | // endregion 208 | 209 | // region Recording State 210 | 211 | /** 212 | * Persists the machine's state. 213 | * 214 | * This method upserts the machine's state history into the database. It returns 215 | * the current state of the machine after the persistence operation. 216 | * 217 | * @return ?State The current state of the machine. 218 | */ 219 | public function persist(): ?State 220 | { 221 | // Retrieve the previous context from the definition's config, or set it to an empty array if not set. 222 | $incrementalContext = $this->definition->initializeContextFromState()->toArray(); 223 | 224 | // Get the last event from the state's history. 225 | $lastHistoryEvent = $this->state->history->last(); 226 | 227 | MachineEvent::upsert( 228 | values: $this->state->history->map(function (MachineEvent $machineEvent, int $index) use (&$incrementalContext, $lastHistoryEvent) { 229 | // Get the context of the current machine event. 230 | $changes = $machineEvent->context; 231 | 232 | // If the current machine event is not the last one, compare its context with the incremental context and get the differences. 233 | if ($machineEvent->id !== $lastHistoryEvent->id && $index > 0) { 234 | $changes = $this->arrayRecursiveDiff($changes, $incrementalContext); 235 | } 236 | 237 | // If there are changes, update the incremental context to the current event's context. 238 | if (!empty($changes)) { 239 | $incrementalContext = $this->arrayRecursiveMerge($incrementalContext, $machineEvent->context); 240 | } 241 | 242 | $machineEvent->context = $changes; 243 | 244 | return array_merge($machineEvent->toArray(), [ 245 | 'created_at' => $machineEvent->created_at->toDateTimeString(), 246 | 'machine_value' => json_encode($machineEvent->machine_value, JSON_THROW_ON_ERROR), 247 | 'payload' => json_encode($machineEvent->payload, JSON_THROW_ON_ERROR), 248 | 'context' => json_encode($machineEvent->context, JSON_THROW_ON_ERROR), 249 | 'meta' => json_encode($machineEvent->meta, JSON_THROW_ON_ERROR), 250 | ]); 251 | })->toArray(), 252 | uniqueBy: ['id'] 253 | ); 254 | 255 | return $this->state; 256 | } 257 | 258 | // endregion 259 | 260 | // region Restoring State 261 | 262 | /** 263 | * Restores the state of the machine from the given root event identifier. 264 | * 265 | * This method queries the machine events based on the provided root event 266 | * identifier. It reconstructs the state of the machine from the queried 267 | * events and returns the restored state. 268 | * 269 | * @param string $key The root event identifier to restore state from. 270 | * 271 | * @return State The restored state of the machine. 272 | * 273 | * @throws RestoringStateException If the machine state is not found. 274 | */ 275 | public function restoreStateFromRootEventId(string $key): State 276 | { 277 | $machineEvents = MachineEvent::query() 278 | ->where('root_event_id', $key) 279 | ->oldest('sequence_number') 280 | ->get(); 281 | 282 | if ($machineEvents->isEmpty()) { 283 | throw RestoringStateException::build('Machine state is not found.'); 284 | } 285 | 286 | $lastMachineEvent = $machineEvents->last(); 287 | 288 | return new State( 289 | context: $this->restoreContext($lastMachineEvent->context), 290 | currentStateDefinition: $this->restoreCurrentStateDefinition($lastMachineEvent->machine_value), 291 | currentEventBehavior: $this->restoreCurrentEventBehavior($lastMachineEvent), 292 | history: $machineEvents, 293 | ); 294 | } 295 | 296 | /** 297 | * Restores the context using the persisted context data. 298 | * 299 | * This method restores the context manager instance based on the persisted 300 | * context data. It utilizes the behavior configuration of the machine's 301 | * definition or defaults to the `ContextManager` class. 302 | * 303 | * @param array $persistedContext The persisted context data. 304 | * 305 | * @return ContextManager The restored context manager instance. 306 | */ 307 | protected function restoreContext(array $persistedContext): ContextManager 308 | { 309 | if (!empty($this->definition->behavior['context'])) { 310 | /** @var ContextManager $contextClass */ 311 | $contextClass = $this->definition->behavior['context']; 312 | 313 | return $contextClass::validateAndCreate($persistedContext); 314 | } 315 | 316 | return ContextManager::validateAndCreate(['data' => $persistedContext]); 317 | } 318 | 319 | /** 320 | * Restores the current state definition based on the given machine value. 321 | * 322 | * This method retrieves the current state definition from the machine's 323 | * definition ID map using the provided machine value. 324 | * 325 | * @param array $machineValue The machine value containing the ID of the state definition. 326 | * 327 | * @return StateDefinition The restored current state definition. 328 | */ 329 | protected function restoreCurrentStateDefinition(array $machineValue): StateDefinition 330 | { 331 | return $this->definition->idMap[$machineValue[0]]; 332 | } 333 | 334 | /** 335 | * Restores the current event behavior based on the given MachineEvent. 336 | * 337 | * This method restores the EventBehavior object based on the provided 338 | * MachineEvent. It determines the source type and constructs the EventBehavior 339 | * object accordingly. 340 | * 341 | * @param MachineEvent $machineEvent The MachineEvent object representing the event. 342 | * 343 | * @return EventBehavior The restored EventBehavior object. 344 | */ 345 | protected function restoreCurrentEventBehavior(MachineEvent $machineEvent): EventBehavior 346 | { 347 | if ($machineEvent->source === SourceType::INTERNAL) { 348 | return EventDefinition::from([ 349 | 'type' => $machineEvent->type, 350 | 'payload' => $machineEvent->payload, 351 | 'version' => $machineEvent->version, 352 | 'source' => SourceType::INTERNAL, 353 | ]); 354 | } 355 | 356 | if (isset($this->definition->behavior[BehaviorType::Event->value][$machineEvent->type])) { 357 | /** @var EventBehavior $eventDefinitionClass */ 358 | $eventDefinitionClass = $this 359 | ->definition 360 | ->behavior[BehaviorType::Event->value][$machineEvent->type]; 361 | 362 | return $eventDefinitionClass::validateAndCreate($machineEvent->payload); 363 | } 364 | 365 | return EventDefinition::from([ 366 | 'type' => $machineEvent->type, 367 | 'payload' => $machineEvent->payload, 368 | 'version' => $machineEvent->version, 369 | 'source' => SourceType::EXTERNAL, 370 | ]); 371 | } 372 | 373 | // endregion 374 | 375 | // region Protected Methods 376 | 377 | /** 378 | * Handles validation guards and throws an exception if any of them fail. 379 | * 380 | * This method processes the machine's validation guards and checks for any 381 | * failures. If any guard fails, it constructs and throws a 382 | * `MachineValidationException` with detailed error messages. 383 | * 384 | * @param int $lastPreviousEventNumber The last previous event sequence number. 385 | * 386 | * @throws MachineValidationException If any validation guards fail. 387 | */ 388 | protected function handleValidationGuards(int $lastPreviousEventNumber): void 389 | { 390 | $machineId = $this->state->currentStateDefinition->machine->id; 391 | 392 | $failedGuardEvents = $this 393 | ->state 394 | ->history 395 | ->filter(fn (MachineEvent $machineEvent) => $machineEvent->sequence_number > $lastPreviousEventNumber) 396 | ->filter(fn (MachineEvent $machineEvent) => preg_match("/{$machineId}\.guard\..*\.fail/", $machineEvent->type)) 397 | ->filter(function (MachineEvent $machineEvent) { 398 | $failedGuardType = explode('.', $machineEvent->type)[2]; 399 | $failedGuardClass = $this->definition->behavior[BehaviorType::Guard->value][$failedGuardType]; 400 | 401 | return is_subclass_of($failedGuardClass, ValidationGuardBehavior::class); 402 | }); 403 | 404 | if ($failedGuardEvents->isNotEmpty()) { 405 | $errorsWithMessage = []; 406 | 407 | foreach ($failedGuardEvents as $failedGuardEvent) { 408 | $errorsWithMessage[$failedGuardEvent->type] = $failedGuardEvent->payload[$failedGuardEvent->type]; 409 | } 410 | 411 | throw MachineValidationException::withMessages($errorsWithMessage); 412 | } 413 | } 414 | 415 | // endregion 416 | 417 | // region Interface Implementations 418 | 419 | /** 420 | * Get the name of the caster class to use when casting from/to this cast target. 421 | * 422 | * This method returns the class name of the caster to be used for casting 423 | * operations. In this case, it returns the `MachineCast` class. 424 | * 425 | * @param array $arguments 426 | * 427 | * @return string The class name of the caster. 428 | */ 429 | public static function castUsing(array $arguments): string 430 | { 431 | return MachineCast::class; 432 | } 433 | 434 | /** 435 | * Returns the JSON serialized representation of the object. 436 | * 437 | * This method returns the JSON serialized representation of the machine object, 438 | * specifically the root event ID from the state's history. 439 | * 440 | * @return string The JSON serialized representation of the object. 441 | */ 442 | public function jsonSerialize(): string 443 | { 444 | return $this->state->history->first()->root_event_id; 445 | } 446 | 447 | /** 448 | * Returns a string representation of the current object. 449 | * 450 | * This method returns a string representation of the machine object, 451 | * specifically the root event ID from the state's history or an empty string. 452 | * 453 | * @return string The string representation of the object. 454 | */ 455 | public function __toString(): string 456 | { 457 | return $this->state->history->first()->root_event_id ?? ''; 458 | } 459 | 460 | // endregion 461 | 462 | /** 463 | * Retrieves the result of the state machine. 464 | * 465 | * This method returns the result of the state machine execution. 466 | * 467 | * If the current state is a final state and a result behavior is 468 | * defined for that state, it applies the result behavior and 469 | * returns the result. Otherwise, it returns null. 470 | * 471 | * @return mixed The result of the state machine. 472 | */ 473 | public function result(): mixed 474 | { 475 | $currentStateDefinition = $this->state->currentStateDefinition; 476 | $behaviorDefinition = $this->definition->behavior[BehaviorType::Result->value]; 477 | 478 | if ($currentStateDefinition->type !== StateDefinitionType::FINAL) { 479 | return null; 480 | } 481 | 482 | $id = $currentStateDefinition->id; 483 | if (!isset($behaviorDefinition[$id])) { 484 | return null; 485 | } 486 | 487 | $resultBehavior = $behaviorDefinition[$id]; 488 | if (!is_callable($resultBehavior)) { 489 | // If the result behavior contains a colon, it means that it has a parameter. 490 | if (str_contains($resultBehavior, ':')) { 491 | [$resultBehavior, $arguments] = explode(':', $resultBehavior); 492 | } 493 | 494 | $resultBehavior = new $resultBehavior(); 495 | } 496 | 497 | /* @var callable $resultBehavior */ 498 | return $resultBehavior( 499 | $this->state->context, 500 | $this->state->currentEventBehavior, 501 | $arguments ?? null, 502 | ); 503 | } 504 | 505 | // region Private Methods 506 | /** 507 | * Compares two arrays recursively and returns the difference. 508 | */ 509 | private function arrayRecursiveDiff(array $array1, array $array2): array 510 | { 511 | $difference = []; 512 | foreach ($array1 as $key => $value) { 513 | if (is_array($value)) { 514 | if (!isset($array2[$key]) || !is_array($array2[$key])) { 515 | $difference[$key] = $value; 516 | } else { 517 | $new_diff = $this->arrayRecursiveDiff($value, $array2[$key]); 518 | if (!empty($new_diff)) { 519 | $difference[$key] = $new_diff; 520 | } 521 | } 522 | } elseif (!array_key_exists($key, $array2) || $array2[$key] !== $value) { 523 | $difference[$key] = $value; 524 | } 525 | } 526 | 527 | return $difference; 528 | } 529 | 530 | /** 531 | * Merges two arrays recursively. 532 | */ 533 | protected function arrayRecursiveMerge(array $array1, array $array2): array 534 | { 535 | $merged = $array1; 536 | 537 | foreach ($array2 as $key => &$value) { 538 | if (is_array($value) && isset($merged[$key]) && is_array($merged[$key])) { 539 | $merged[$key] = $this->arrayRecursiveMerge($merged[$key], $value); 540 | } else { 541 | $merged[$key] = $value; 542 | } 543 | } 544 | 545 | return $merged; 546 | } 547 | // endregion 548 | } 549 | -------------------------------------------------------------------------------- /src/Definition/MachineDefinition.php: -------------------------------------------------------------------------------- 1 | 38 | */ 39 | public array $idMap = []; 40 | 41 | /** 42 | * The child state definitions of this state definition. 43 | * 44 | * @var array|null 45 | */ 46 | public ?array $stateDefinitions = null; 47 | 48 | /** 49 | * The events that can be accepted by this machine definition. 50 | * 51 | * @var null|array 52 | */ 53 | public ?array $events = null; 54 | 55 | /** Represents a queue for storing events that raised during the execution of the transition. */ 56 | public Collection $eventQueue; 57 | 58 | /** The initial state definition for this machine definition. */ 59 | public ?StateDefinition $initialStateDefinition = null; 60 | 61 | /** Indicates whether the scenario is enabled. */ 62 | public bool $scenariosEnabled = false; 63 | 64 | /** machine-based variable that determines whether to persist the state change. */ 65 | public bool $shouldPersist = true; 66 | 67 | // endregion 68 | 69 | // region Constructor 70 | 71 | /** 72 | * Create a new machine definition with the given arguments. 73 | * 74 | * @param array|null $config The raw configuration array used to create the machine definition. 75 | * @param array|null $behavior The implementation of the machine behavior that defined in the machine definition. 76 | * @param string $id The id of the machine. 77 | * @param string|null $version The version of the machine. 78 | * @param string $delimiter The string delimiter for serializing the path to a string. 79 | */ 80 | private function __construct( 81 | public ?array $config, 82 | public ?array $behavior, 83 | public string $id, 84 | public ?string $version, 85 | public ?array $scenarios, 86 | public string $delimiter = self::STATE_DELIMITER, 87 | ) { 88 | StateConfigValidator::validate($config); 89 | 90 | $this->scenariosEnabled = isset($this->config['scenarios_enabled']) && $this->config['scenarios_enabled'] === true; 91 | 92 | $this->shouldPersist = $this->config['should_persist'] ?? $this->shouldPersist; 93 | 94 | $this->root = $this->createRootStateDefinition($config); 95 | 96 | // Checks if the scenario is enabled, and if true, creates scenario state definitions. 97 | if ($this->scenariosEnabled) { 98 | $this->createScenarioStateDefinitions(); 99 | } 100 | 101 | $this->root->initializeTransitions(); 102 | 103 | $this->stateDefinitions = $this->root->stateDefinitions; 104 | $this->events = $this->root->events; 105 | 106 | $this->checkFinalStatesForTransitions(); 107 | 108 | $this->eventQueue = new Collection(); 109 | 110 | $this->initialStateDefinition = $this->root->initialStateDefinition; 111 | 112 | $this->setupContextManager(); 113 | } 114 | 115 | // endregion 116 | 117 | // region Static Constructors 118 | 119 | /** 120 | * Define a new machine with the given configuration and behavior. 121 | * 122 | * @param ?array $config The raw configuration array used to create the machine. 123 | * @param array|null $behavior An array of behavior options. 124 | * 125 | * @return self The created machine definition. 126 | */ 127 | public static function define( 128 | ?array $config = null, 129 | ?array $behavior = null, 130 | ?array $scenarios = null, 131 | ): self { 132 | return new self( 133 | config: $config ?? null, 134 | behavior: array_merge(self::initializeEmptyBehavior(), $behavior ?? []), 135 | id: $config['id'] ?? self::DEFAULT_ID, 136 | version: $config['version'] ?? null, 137 | scenarios: $scenarios, 138 | delimiter: $config['delimiter'] ?? self::STATE_DELIMITER, 139 | ); 140 | } 141 | 142 | // endregion 143 | 144 | // region Protected Methods 145 | 146 | /** 147 | * Initializes an empty behavior array with empty events, actions and guard arrays. 148 | * 149 | * @return array An empty behavior array with empty events, actions and guard arrays. 150 | */ 151 | protected static function initializeEmptyBehavior(): array 152 | { 153 | $behaviorArray = []; 154 | 155 | foreach (BehaviorType::cases() as $behaviorType) { 156 | $behaviorArray[$behaviorType->value] = []; 157 | } 158 | 159 | return $behaviorArray; 160 | } 161 | 162 | /** 163 | * Create the root state definition. 164 | * 165 | * Creates and returns a new instance of `StateDefinition` with the given configuration. 166 | * If no configuration is provided, the configuration will be set to null. 167 | * The $options parameter is set with the current `Machine` and machine id. 168 | * 169 | * @param array|null $config The configuration for the root state definition. 170 | * 171 | * @return StateDefinition The created root state definition. 172 | */ 173 | protected function createRootStateDefinition(?array $config): StateDefinition 174 | { 175 | return new StateDefinition( 176 | config: $config ?? null, 177 | options: [ 178 | 'machine' => $this, 179 | 'key' => $this->id, 180 | ] 181 | ); 182 | } 183 | 184 | /** 185 | * Creates scenario state definitions based on the defined scenarios. 186 | * 187 | * This method iterates through the specified scenarios and creates StateDefinition objects 188 | * for each, with the provided states configuration. 189 | */ 190 | protected function createScenarioStateDefinitions(): void 191 | { 192 | if (!empty($this->scenarios)) { 193 | foreach ($this->scenarios as $name => $scenarios) { 194 | $parentStateDefinition = reset($this->idMap); 195 | $state = new StateDefinition( 196 | config: ['states' => $scenarios], 197 | options: [ 198 | 'parent' => $parentStateDefinition, 199 | 'machine' => $this, 200 | 'key' => $name, 201 | ] 202 | ); 203 | 204 | $state->initializeTransitions(); 205 | } 206 | } 207 | } 208 | 209 | /** 210 | * Build the initial state for the machine. 211 | * 212 | * @return ?State The initial state of the machine. 213 | */ 214 | public function getInitialState(EventBehavior|array|null $event = null): ?State 215 | { 216 | if (is_null($this->initialStateDefinition)) { 217 | return null; 218 | } 219 | 220 | $context = $this->initializeContextFromState(); 221 | 222 | $initialState = $this->buildCurrentState( 223 | context: $context, 224 | currentStateDefinition: $this->initialStateDefinition, 225 | ); 226 | 227 | $initialState = $this->getScenarioStateIfAvailable(state: $initialState, eventBehavior: $event ?? null); 228 | $this->initialStateDefinition = $initialState->currentStateDefinition; 229 | 230 | // Record the internal machine init event. 231 | $initialState->setInternalEventBehavior(type: InternalEvent::MACHINE_START); 232 | 233 | // Record the internal initial state init event. 234 | $initialState->setInternalEventBehavior( 235 | type: InternalEvent::STATE_ENTER, 236 | placeholder: $initialState->currentStateDefinition->route, 237 | ); 238 | 239 | // Run entry actions on the initial state definition 240 | $this->initialStateDefinition->runEntryActions( 241 | state: $initialState, 242 | eventBehavior: $initialState->currentEventBehavior, 243 | ); 244 | 245 | if ($this->initialStateDefinition?->transitionDefinitions !== null) { 246 | foreach ($this->initialStateDefinition->transitionDefinitions as $transition) { 247 | if ($transition->isAlways === true) { 248 | return $this->transition( 249 | event: [ 250 | 'type' => TransitionProperty::Always->value, 251 | 'actor' => $initialState->currentEventBehavior->actor($context), 252 | ], 253 | state: $initialState 254 | ); 255 | } 256 | } 257 | } 258 | 259 | if ($this->eventQueue->isNotEmpty()) { 260 | $firstEvent = $this->eventQueue->shift(); 261 | 262 | $eventBehavior = $this->initializeEvent($firstEvent, $initialState); 263 | 264 | return $this->transition($eventBehavior, $initialState); 265 | } 266 | 267 | // Record the machine finish event if the initial state is a final state. 268 | if ($initialState->currentStateDefinition->type === StateDefinitionType::FINAL) { 269 | $initialState->setInternalEventBehavior( 270 | type: InternalEvent::MACHINE_FINISH, 271 | placeholder: $initialState->currentStateDefinition->route 272 | ); 273 | } 274 | 275 | return $initialState; 276 | } 277 | 278 | /** 279 | * Retrieves the scenario state if scenario is enabled and available; otherwise, returns the current state. 280 | * 281 | * @param State $state The current state. 282 | * @param EventBehavior|array|null $eventBehavior The optional event behavior or event data. 283 | * 284 | * @return State|null The scenario state if scenario is enabled and found, otherwise returns the current state. 285 | */ 286 | public function getScenarioStateIfAvailable(State $state, EventBehavior|array|null $eventBehavior = null): ?State 287 | { 288 | if ($this->scenariosEnabled === false) { 289 | return $state; 290 | } 291 | 292 | if ($eventBehavior !== null) { 293 | // Initialize the event and validate it 294 | $eventBehavior = $this->initializeEvent($eventBehavior, $state); 295 | if ($eventBehavior->getScenario() !== null) { 296 | $state->context->set('scenarioType', $eventBehavior->getScenario()); 297 | } 298 | } 299 | 300 | $scenarioStateKey = str_replace($this->id, $this->id.$this->delimiter.$state->context->get('scenarioType'), $state->currentStateDefinition->id); 301 | if (isset($this->idMap[$scenarioStateKey]) && $state->context->has('scenarioType')) { 302 | return $state->setCurrentStateDefinition(stateDefinition: $this->idMap[$scenarioStateKey]); 303 | } 304 | 305 | return $state; 306 | } 307 | 308 | /** 309 | * Builds the current state of the state machine. 310 | * 311 | * This method creates a new State object, populating it with 312 | * the active state definition and the current context data. 313 | * If no current state is provided, the initial state is used. 314 | * 315 | * @param StateDefinition|null $currentStateDefinition The current state definition, if any. 316 | * 317 | * @return State The constructed State object representing the current state. 318 | */ 319 | protected function buildCurrentState( 320 | ContextManager $context, 321 | ?StateDefinition $currentStateDefinition = null, 322 | ?EventBehavior $eventBehavior = null, 323 | ): State { 324 | return new State( 325 | context: $context, 326 | currentStateDefinition: $currentStateDefinition ?? $this->initialStateDefinition, 327 | currentEventBehavior: $eventBehavior, 328 | ); 329 | } 330 | 331 | /** 332 | * Get the current state definition. 333 | * 334 | * If a `State` object is passed, return its active state definition. 335 | * Otherwise, lookup the state in the `MachineDefinition` states array. 336 | * If the state is not found, return the initial state. 337 | * 338 | * @param string|State|null $state The state to retrieve the definition for. 339 | * 340 | * @return mixed The state definition. 341 | */ 342 | protected function getCurrentStateDefinition(string|State|null $state): mixed 343 | { 344 | return $state instanceof State 345 | ? $state->currentStateDefinition 346 | : $this->stateDefinitions[$state] ?? $this->initialStateDefinition; 347 | } 348 | 349 | /** 350 | * Initializes the context for the state machine. 351 | * 352 | * This method checks if the context is defined in the machine's 353 | * configuration and creates a new `ContextManager` instance 354 | * accordingly. It supports context defined as an array or a class 355 | * name. 356 | * 357 | * @return ContextManager The initialized context manager 358 | */ 359 | public function initializeContextFromState(?State $state = null): ContextManager 360 | { 361 | // If a state is provided, use it's context 362 | if (!is_null($state)) { 363 | return $state->context; 364 | } 365 | 366 | // If a context class is provided, use it to create the context 367 | if (!empty($this->behavior['context'])) { 368 | /** @var ContextManager $contextClass */ 369 | $contextClass = $this->behavior['context']; 370 | 371 | return $contextClass::validateAndCreate($this->config['context'] ?? []); 372 | } 373 | 374 | // Otherwise, use the context defined in the machine config 375 | $contextConfig = $this->config['context'] ?? []; 376 | 377 | return ContextManager::validateAndCreate(['data' => $contextConfig]); 378 | } 379 | 380 | /** 381 | * Set up the context manager. 382 | * 383 | * If a context manager class is specified in the configuration, 384 | * assign it to the `$behavior['context']` property and clear the `$config['context']` array. 385 | */ 386 | public function setupContextManager(): void 387 | { 388 | if (isset($this->config['context']) && is_subclass_of($this->config['context'], ContextManager::class)) { 389 | $this->behavior['context'] = $this->config['context']; 390 | 391 | $this->config['context'] = []; 392 | } 393 | } 394 | 395 | /** 396 | * Retrieve an invokable behavior instance or callable. 397 | * 398 | * This method checks if the given behavior definition is a valid class and a 399 | * subclass of InvokableBehavior. If not, it looks up the behavior in the 400 | * provided behavior type map. If the behavior is still not found, it returns 401 | * null. 402 | * 403 | * @param string $behaviorDefinition The behavior definition to look up. 404 | * @param BehaviorType $behaviorType The type of the behavior (e.g., guard or action). 405 | * 406 | * @return callable|\Tarfinlabs\EventMachine\Behavior\InvokableBehavior|null The invokable behavior instance or callable, or null if not found. 407 | */ 408 | public function getInvokableBehavior(string $behaviorDefinition, BehaviorType $behaviorType): null|callable|InvokableBehavior 409 | { 410 | // If the guard definition is an invokable GuardBehavior, create a new instance. 411 | if (is_subclass_of($behaviorDefinition, InvokableBehavior::class)) { 412 | /* @var callable $behaviorDefinition */ 413 | return new $behaviorDefinition($this->eventQueue); 414 | } 415 | 416 | // If the guard definition is defined in the machine behavior, retrieve it. 417 | $invokableBehavior = $this->behavior[$behaviorType->value][$behaviorDefinition] ?? null; 418 | 419 | // If the retrieved behavior is not null and not callable, create a new instance. 420 | if ($invokableBehavior !== null && !is_callable($invokableBehavior)) { 421 | /** @var InvokableBehavior $invokableInstance */ 422 | $invokableInstance = new $invokableBehavior($this->eventQueue); 423 | 424 | return $invokableInstance; 425 | } 426 | 427 | if ($invokableBehavior === null) { 428 | throw BehaviorNotFoundException::build($behaviorDefinition); 429 | } 430 | 431 | return $invokableBehavior; 432 | } 433 | 434 | /** 435 | * Initialize an EventDefinition instance from the given event and state. 436 | * 437 | * If the $event argument is already an EventDefinition instance, 438 | * return it directly. Otherwise, create an EventDefinition instance 439 | * by invoking the behavior for the corresponding event type in the given 440 | * state. If no behavior is defined for the event type, a default 441 | * EventDefinition instance is returned. 442 | * 443 | * @param EventBehavior|array $event The event to initialize. 444 | * @param State $state The state in which the event is occurring. 445 | * 446 | * @return EventBehavior The initialized EventBehavior instance. 447 | */ 448 | protected function initializeEvent( 449 | EventBehavior|array $event, 450 | State $state 451 | ): EventBehavior { 452 | if ($event instanceof EventBehavior) { 453 | return $event; 454 | } 455 | 456 | if (isset($state->currentStateDefinition->machine->behavior[BehaviorType::Event->value][$event['type']])) { 457 | /** @var EventBehavior $eventDefinitionClass */ 458 | $eventDefinitionClass = $state 459 | ->currentStateDefinition 460 | ->machine 461 | ->behavior[BehaviorType::Event->value][$event['type']]; 462 | 463 | return $eventDefinitionClass::validateAndCreate($event); 464 | } 465 | 466 | return EventDefinition::from($event); 467 | } 468 | 469 | /** 470 | * Retrieves the nearest `StateDefinition` by string. 471 | * 472 | * @param string $stateDefinitionId The state string. 473 | * 474 | * @return StateDefinition|null The nearest StateDefinition or null if it is not found. 475 | */ 476 | public function getNearestStateDefinitionByString(string $stateDefinitionId): ?StateDefinition 477 | { 478 | if (empty($stateDefinitionId)) { 479 | return null; 480 | } 481 | 482 | $stateDefinitionId = $this->id.$this->delimiter.$stateDefinitionId; 483 | 484 | return $this->idMap[$stateDefinitionId] ?? null; 485 | } 486 | 487 | /** 488 | * Check final states for invalid transition definitions. 489 | * 490 | * Iterates through the state definitions in the `idMap` property and checks if any of the final states 491 | * have transition definitions. If a final state has transition definitions, it throws an `InvalidFinalStateDefinitionException`. 492 | */ 493 | public function checkFinalStatesForTransitions(): void 494 | { 495 | foreach ($this->idMap as $stateDefinition) { 496 | if ( 497 | $stateDefinition->type === StateDefinitionType::FINAL && 498 | $stateDefinition->transitionDefinitions !== null 499 | ) { 500 | throw InvalidFinalStateDefinitionException::noTransitions($stateDefinition->id); 501 | } 502 | } 503 | } 504 | 505 | /** 506 | * Find the transition definition based on the current state definition and event behavior. 507 | * 508 | * If the transition definition for the given event type is found in the current state definition, 509 | * return it. If the current state definition has a parent, recursively search for the transition 510 | * definition in the parent state definition. If no transition definition is found and the current 511 | * state definition is not the initial state, throw an exception. 512 | * 513 | * @param \Tarfinlabs\EventMachine\Definition\StateDefinition $currentStateDefinition The current state definition. 514 | * @param \Tarfinlabs\EventMachine\Behavior\EventBehavior $eventBehavior The event behavior. 515 | * @param string|null $firstStateDefinitionId The ID of the first state definition encountered during recursion. 516 | * 517 | * @return \Tarfinlabs\EventMachine\Definition\TransitionDefinition|null The found transition definition, or null if none is found. 518 | * 519 | * @throws \Tarfinlabs\EventMachine\Exceptions\NoTransitionDefinitionFoundException If no transition definition is found for the event type. 520 | */ 521 | protected function findTransitionDefinition( 522 | StateDefinition $currentStateDefinition, 523 | EventBehavior $eventBehavior, 524 | ?string $firstStateDefinitionId = null, 525 | ): ?TransitionDefinition { 526 | $transitionDefinition = $currentStateDefinition->transitionDefinitions[$eventBehavior->type] ?? null; 527 | 528 | // If no transition definition is found, and the current state definition has a parent, 529 | // recursively search for the transition definition in the parent state definition. 530 | if ( 531 | $transitionDefinition === null && 532 | $currentStateDefinition->order !== 0 533 | ) { 534 | return $this->findTransitionDefinition( 535 | currentStateDefinition: $currentStateDefinition->parent, 536 | eventBehavior: $eventBehavior, 537 | firstStateDefinitionId: $currentStateDefinition->id 538 | ); 539 | } 540 | 541 | // Throw exception if no transition definition is found for the event type 542 | if ($transitionDefinition === null) { 543 | throw NoTransitionDefinitionFoundException::build($eventBehavior->type, $firstStateDefinitionId); 544 | } 545 | 546 | return $transitionDefinition; 547 | } 548 | 549 | // endregion 550 | 551 | // region Public Methods 552 | 553 | /** 554 | * Transition the state machine to a new state based on an event. 555 | * 556 | * @param EventBehavior|array $event The event that triggers the transition. 557 | * @param State|null $state The current state or state name, or null to use the initial state. 558 | * 559 | * @return State The new state after the transition. 560 | */ 561 | public function transition( 562 | EventBehavior|array $event, 563 | ?State $state = null 564 | ): State { 565 | if ($state !== null) { 566 | $state = $this->getScenarioStateIfAvailable(state: $state, eventBehavior: $event); 567 | } else { 568 | // Use the initial state if no state is provided 569 | $state = $this->getInitialState(event: $event); 570 | } 571 | 572 | $currentStateDefinition = $this->getCurrentStateDefinition($state); 573 | 574 | // Initialize the event and validate it 575 | $eventBehavior = $this->initializeEvent($event, $state); 576 | $eventBehavior->selfValidate(); 577 | 578 | $state->setCurrentEventBehavior($eventBehavior); 579 | 580 | /** 581 | * Get the transition definition for the current event type. 582 | * 583 | * @var null|array|TransitionDefinition $transitionDefinition 584 | */ 585 | $transitionDefinition = $this->findTransitionDefinition($currentStateDefinition, $eventBehavior); 586 | 587 | // Record transition start event 588 | $state->setInternalEventBehavior( 589 | type: InternalEvent::TRANSITION_START, 590 | placeholder: "{$state->currentStateDefinition->route}.{$eventBehavior->type}", 591 | ); 592 | 593 | $transitionBranch = $transitionDefinition->getFirstValidTransitionBranch( 594 | eventBehavior: $eventBehavior, 595 | state: $state 596 | ); 597 | 598 | // If no valid transition branch is found, return the current state 599 | if ($transitionBranch === null) { 600 | // Record transition abort event 601 | $state->setInternalEventBehavior( 602 | type: InternalEvent::TRANSITION_FAIL, 603 | placeholder: "{$state->currentStateDefinition->route}.{$eventBehavior->type}", 604 | ); 605 | 606 | return $state->setCurrentStateDefinition($currentStateDefinition); 607 | } 608 | 609 | // If a target state definition is defined, find its initial state definition 610 | $targetStateDefinition = $transitionBranch->target?->findInitialStateDefinition() ?? $transitionBranch->target; 611 | 612 | // Execute actions associated with the transition 613 | $transitionBranch->runActions($state, $eventBehavior); 614 | 615 | // Record transition start finish 616 | $state->setInternalEventBehavior( 617 | type: InternalEvent::TRANSITION_FINISH, 618 | placeholder: "{$state->currentStateDefinition->route}.{$eventBehavior->type}", 619 | ); 620 | 621 | // Execute exit actions for the current state definition 622 | $transitionBranch->transitionDefinition->source->runExitActions($state); 623 | 624 | // Record state exit event 625 | $state->setInternalEventBehavior( 626 | type: InternalEvent::STATE_EXIT, 627 | placeholder: $state->currentStateDefinition->route, 628 | ); 629 | 630 | // Set the new state, or keep the current state if no target state definition is defined 631 | $newState = $state 632 | ->setCurrentStateDefinition($targetStateDefinition ?? $currentStateDefinition); 633 | 634 | // Get scenario state if exists 635 | $newState = $this->getScenarioStateIfAvailable(state: $newState, eventBehavior: $eventBehavior); 636 | if ($targetStateDefinition !== null && $targetStateDefinition->id !== $newState->currentStateDefinition->id) { 637 | $targetStateDefinition = $newState->currentStateDefinition; 638 | } 639 | 640 | // Record state enter event 641 | $state->setInternalEventBehavior( 642 | type: InternalEvent::STATE_ENTER, 643 | placeholder: $state->currentStateDefinition->route, 644 | ); 645 | 646 | // Execute entry actions for the new state definition 647 | $targetStateDefinition?->runEntryActions($newState, $eventBehavior); 648 | 649 | // Check if the new state has any transitions that are always taken 650 | if ($this->idMap[$newState->currentStateDefinition->id]->transitionDefinitions !== null) { 651 | /** @var TransitionDefinition $transition */ 652 | foreach ($this->idMap[$newState->currentStateDefinition->id]->transitionDefinitions as $transition) { 653 | if ($transition->isAlways === true) { 654 | // If an always-taken transition is found, perform the transition 655 | return $this->transition( 656 | event: [ 657 | 'type' => TransitionProperty::Always->value, 658 | 'actor' => $eventBehavior->actor($newState->context), 659 | ], 660 | state: $newState 661 | ); 662 | } 663 | } 664 | } 665 | 666 | // If there are events in the queue, process the first event 667 | if ($this->eventQueue->isNotEmpty()) { 668 | $firstEvent = $this->eventQueue->shift(); 669 | 670 | $eventBehavior = $this->initializeEvent($firstEvent, $newState); 671 | 672 | return $this->transition($eventBehavior, $newState); 673 | } 674 | 675 | // Record the machine finish event if the initial state is a final state. 676 | if ($state->currentStateDefinition->type === StateDefinitionType::FINAL) { 677 | $state->setInternalEventBehavior( 678 | type: InternalEvent::MACHINE_FINISH, 679 | placeholder: $state->currentStateDefinition->route, 680 | ); 681 | } 682 | 683 | return $newState; 684 | } 685 | 686 | /** 687 | * Executes the action associated with the provided action definition. 688 | * 689 | * This method retrieves the appropriate action behavior based on the 690 | * action definition, and if the action behavior is callable, it 691 | * executes it using the context and event payload. 692 | * 693 | * @param string $actionDefinition The action definition, either a class 694 | * @param EventBehavior|null $eventBehavior The event (optional). 695 | * 696 | * @throws \ReflectionException 697 | */ 698 | public function runAction( 699 | string $actionDefinition, 700 | State $state, 701 | ?EventBehavior $eventBehavior = null 702 | ): void { 703 | [$actionDefinition, $actionArguments] = array_pad(explode(':', $actionDefinition, 2), 2, null); 704 | $actionArguments = $actionArguments === null ? [] : explode(',', $actionArguments); 705 | 706 | // Retrieve the appropriate action behavior based on the action definition. 707 | $actionBehavior = $this->getInvokableBehavior( 708 | behaviorDefinition: $actionDefinition, 709 | behaviorType: BehaviorType::Action 710 | ); 711 | 712 | $shouldLog = $actionBehavior?->shouldLog ?? false; 713 | 714 | // If the action behavior is callable, execute it with the context and event payload. 715 | if (!is_callable($actionBehavior)) { 716 | return; 717 | } 718 | 719 | // Record the internal action init event. 720 | $state->setInternalEventBehavior( 721 | type: InternalEvent::ACTION_START, 722 | placeholder: $actionDefinition, 723 | shouldLog: $shouldLog, 724 | ); 725 | 726 | if ($actionBehavior instanceof InvokableBehavior) { 727 | $actionBehavior::validateRequiredContext($state->context); 728 | } 729 | 730 | // Get the number of events in the queue before the action is executed. 731 | $numberOfEventsInQueue = $this->eventQueue->count(); 732 | 733 | // Inject action behavior parameters 734 | $actionBehaviorParemeters = InvokableBehavior::injectInvokableBehaviorParameters( 735 | actionBehavior: $actionBehavior, 736 | state: $state, 737 | eventBehavior: $eventBehavior, 738 | actionArguments: $actionArguments 739 | ); 740 | 741 | // Execute the action behavior 742 | ($actionBehavior)(...$actionBehaviorParemeters); 743 | 744 | // Get the number of events in the queue after the action is executed. 745 | $newNumberOfEventsInQueue = $this->eventQueue->count(); 746 | 747 | // If the number of events in the queue has changed, get the new events to create history. 748 | if ($numberOfEventsInQueue !== $newNumberOfEventsInQueue) { 749 | // Get new events from the queue 750 | $newEvents = $this->eventQueue->slice($numberOfEventsInQueue, $newNumberOfEventsInQueue); 751 | 752 | foreach ($newEvents as $newEvent) { 753 | $state->setInternalEventBehavior( 754 | type: InternalEvent::EVENT_RAISED, 755 | placeholder: is_array($newEvent) ? $newEvent['type'] : $newEvent->type, 756 | ); 757 | } 758 | } 759 | 760 | // Validate the context after the action is executed. 761 | $state->context->selfValidate(); 762 | 763 | // Record the internal action done event. 764 | $state->setInternalEventBehavior( 765 | type: InternalEvent::ACTION_FINISH, 766 | placeholder: $actionDefinition, 767 | shouldLog: $shouldLog, 768 | ); 769 | } 770 | 771 | // endregion 772 | } 773 | --------------------------------------------------------------------------------