├── .github └── workflows │ └── main.yml ├── LICENSE.md ├── README.md ├── composer.json ├── config └── workflow.php ├── database └── migrations │ └── create_state_workflow_histories_table.php ├── phpunit.xml └── src ├── Console └── Commands │ └── StateWorkflowDumpCommand.php ├── Events ├── BaseEvent.php ├── CompletedEvent.php ├── EnterEvent.php ├── EnteredEvent.php ├── GuardEvent.php ├── LeaveEvent.php └── TransitionEvent.php ├── Interfaces ├── StateWorkflowInterface.php ├── WorkflowEventSubscriberInterface.php └── WorkflowRegistryInterface.php ├── Models └── StateWorkflowHistory.php ├── StateWorkflowServiceProvider.php ├── Subscribers ├── WorkflowSubscriber.php └── WorkflowSubscriberHandler.php ├── Traits └── HasWorkflowTrait.php ├── Workflow ├── MethodMarkingStore.php └── StateWorkflow.php └── WorkflowRegistry.php /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Unit Test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | - main 8 | push: 9 | branches: 10 | - master 11 | - main 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | php: [8.0, 8.1, 8.2] 21 | laravel: [9.*, 10.*] 22 | dependency-version: [prefer-lowest, prefer-stable] 23 | exclude: 24 | - laravel: 10.* 25 | php: 8.0 26 | 27 | name: PHP${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} 28 | 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v3 32 | 33 | - name: Setup PHP 34 | uses: shivammathur/setup-php@v2 35 | with: 36 | php-version: ${{matrix.php}} 37 | 38 | - name: Cache Composer dependencies 39 | uses: actions/cache@v3 40 | with: 41 | path: ${{ steps.composer-cache.outputs.dir }} 42 | # Use composer.json for key, if composer.lock is not committed. 43 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} 44 | # key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 45 | restore-keys: ${{ runner.os }}-composer- 46 | 47 | - name: Install Composer dependencies 48 | run: | 49 | composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update 50 | composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction 51 | 52 | - name: Run PHPUnit tests 53 | run: | 54 | vendor/bin/phpunit --testdox 55 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 RIMU (Ringier International Market Unit) 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel State workflow 2 | [![Unit Test](https://github.com/RingierIMU/state-workflow/actions/workflows/main.yml/badge.svg)](https://github.com/RingierIMU/state-workflow/actions/workflows/main.yml) 3 | 4 | **Laravel State workflow** provide tools for defining and managing workflows and activities with ease. 5 | It offers an object oriented way to define a process or a life cycle that your object goes through. 6 | Each step or stage in the process is called a state. You do also define transitions that describe the action to get from one state to another. 7 | 8 | A workflow consist of **state** and **actions** to get from one state to another. 9 | These **actions** are called **transitions** which describes how to get from one state to another. 10 | ## Installation 11 | ``` 12 | $ composer require ringierimu/state-workflow 13 | ``` 14 | 15 | Publish `config/workflow.php` file 16 | ```php 17 | $ php artisan vendor:publish --provider="Ringierimu\StateWorkflow\StateWorkflowServiceProvider" 18 | ``` 19 | Run migrations 20 | ``` 21 | $ php artisan migrate 22 | ``` 23 | ## Configuration 24 | 1. Open `config/workflow.php` and configure it 25 | ```php 26 | // this should be your model name in camelcase. eg. PropertyListing::Class => propertyListing 27 | 'post' => [ 28 | // class of your domain object 29 | 'class' => \App\Post::class, 30 | 31 | // Register subscriber for this workflow which contains business rules. Uncomment line below to register subscriber 32 | //'subscriber' => \App\Listeners\UserEventSubscriber::class, 33 | 34 | // property of your object holding the actual state (default is "current_state") 35 | //'property_path' => 'current_state', //uncomment this line to override default value 36 | 37 | // list of all possible states 38 | 'states' => [ 39 | 'new', 40 | 'pending_activation', 41 | 'activated', 42 | 'deleted', 43 | 'blocked' 44 | ], 45 | 46 | // list of all possible transitions 47 | 'transitions' => [ 48 | 'create' => [ 49 | 'from' => ['new'], 50 | 'to' => 'pending_activation', 51 | ], 52 | 'activate' => [ 53 | 'from' => ['pending_activation'], 54 | 'to' => 'activated', 55 | ], 56 | 'block' => [ 57 | 'from' => ['pending_activation', 'activated'], 58 | 'to' => 'blocked' 59 | ], 60 | 'delete' => [ 61 | 'from' => ['pending_activation', 'activated', 'blocked'], 62 | 'to' => 'deleted', 63 | ], 64 | ], 65 | ], 66 | ``` 67 | 2. Add `HasWorkflowTrait` to your model class to support workflow 68 | ```php 69 | applyTransition("create"); 106 | $post = $post->refresh(); 107 | 108 | //Return current_state value 109 | $post->state(); //pending_activation 110 | 111 | //Check if this transition is allowed 112 | $post->canTransition("activate"); // True 113 | 114 | //Return Model state history 115 | $post->stateHistory(); 116 | ``` 117 | 118 | ### Authenticated User Resolver 119 | Ability to audit and track who action a specific state change for your object. 120 | The package leverage the default Laravel auth provider to resolve the authenticated user when applying the state changes. 121 | 122 | For a custom authentication mechanism, you should override `authenticatedUserId` in your object class with your own implementation. 123 | 124 | ```php 125 | /** 126 | * Return authenticated user id. 127 | * 128 | * @return int|null 129 | */ 130 | public function authenticatedUserId() 131 | { 132 | // Implement authenticated user resolver 133 | } 134 | 135 | ``` 136 | ### Fired Event 137 | Each step has three events that are fired in order: 138 | - An event for every workflow 139 | - An event for the workflow concerned 140 | - An event for the workflow concerned with the specific transition or state name 141 | 142 | During state/workflow transition, the following events are fired in the following order: 143 | 1. Validate whether the transition is allowed at all. 144 | Their event listeners are invoked every time a call to 145 | `workflow()->can()`, `workflow()->apply()` or `workflow()->getEnabledTransitions()` is executed. `Guard Event` 146 | ```php 147 | workflow.guard 148 | workflow.[workflow name].guard 149 | workflow.[workflow name].guard.[transition name] 150 | ``` 151 | 2. The subject is about to leave a state. `Leave Event` 152 | ```php 153 | workflow.leave 154 | workflow.[workflow name].leave 155 | workflow.[workflow name].leave.[state name] 156 | ``` 157 | 3. The subject is going through this transition. `Transition Event` 158 | ```php 159 | workflow.transition 160 | workflow.[workflow name].transition 161 | workflow.[workflow name].transition.[transition name] 162 | ``` 163 | 4. The subject is about to enter a new state. 164 | This event is triggered just before the subject states are updated. `Enter Event` 165 | ```php 166 | workflow.enter 167 | workflow.[workflow name].enter 168 | workflow.[workflow name].enter.[state name] 169 | ``` 170 | 5. The subject has entered in the states and is updated. `Entered Event` 171 | ```php 172 | workflow.entered 173 | workflow.[workflow name].entered 174 | workflow.[workflow name].entered.[state name] 175 | ``` 176 | 6. The subject has completed this transition. `Completed Event` 177 | ```php 178 | workflow.completed 179 | workflow.[workflow name].completed 180 | workflow.[workflow name].completed.[transition name] 181 | ``` 182 | ### Subscriber 183 | Create subscriber class to listen to those events and the class should `extends WorkflowSubscriberHandler`. 184 | 185 | To register method to listen to specific even within subscriber use the following format for method name: 186 | - on[Event] - `onGuard()` 187 | - on[Event][Transition/State name] - `onGuardActivate()` 188 | 189 | NB: 190 | - Method name must start with `on` key word otherwise it will be ignored. 191 | - `Subscriber` class must be register inside `workflow.php` config file with the appropriate workflow configuration. 192 | - `Subscriber` class must extends `WorkflowSubscriberHandler`. 193 | - `Guard`, `Transition` and `Completed` Event uses of transition name. 194 | - `Leave`, `Enter` and `Entered` Event uses state name. 195 | ```php 196 | getOriginalEvent()->getSubject(); 219 | 220 | if (empty($user->dob)) { 221 | // Users with no dob should not be allowed 222 | $event->getOriginalEvent()->setBlocked(true); 223 | } 224 | } 225 | 226 | /** 227 | * Handle workflow leave event. 228 | * 229 | * @param LeaveEvent $event 230 | */ 231 | public function onLeavePendingActivation($event) 232 | { 233 | } 234 | 235 | /** 236 | * Handle workflow transition event. 237 | * 238 | * @param TransitionEvent $event 239 | */ 240 | public function onTransitionActivate($event) 241 | { 242 | } 243 | 244 | /** 245 | * Handle workflow enter event. 246 | * 247 | * @param EnterEvent $event 248 | */ 249 | public function onEnterActivated($event) 250 | { 251 | } 252 | 253 | /** 254 | * Handle workflow entered event. 255 | * 256 | * @param EnteredEvent $event 257 | */ 258 | public function onEnteredActivated($event) 259 | { 260 | } 261 | } 262 | ``` 263 | 264 | ## Event Methods 265 | Each workflow event has an instance of `Event`. This means that each event has access to the following information: 266 | - `getOriginalEvent()`: Returns the Parent Event that dispatched the event which has the following children methods: 267 | - `getSubject()`: Returns the object that dispatches the event. 268 | - `getTransition()`: Returns the Transition that dispatches the event. 269 | - `getWorkflowName()`: Returns a string with the name of the workflow that triggered the event. 270 | - `isBlocked()`: Returns true/false if transition is blocked. 271 | - `setBlocked()`: Sets the blocked value. 272 | 273 | ## Artisan Command 274 | Symfony workflow uses GraphvizDumper to create the workflow image by using the `dot` command. 275 | The `dot` command is part of [Graphviz](http://www.graphviz.org). 276 | 277 | You will be required to download `dot` command to make use of this command. 278 | [https://graphviz.gitlab.io/download/](https://graphviz.gitlab.io/download/) 279 | 280 | ### Usage 281 | ```php 282 | php artisan workflow:dump workflow_name 283 | ``` 284 | 285 | ## Run Unit Test 286 | ```bash 287 | composer test 288 | ``` 289 | 290 | ## Credits 291 | - [Symfony Workflow](https://symfony.com/doc/current/components/workflow.html) 292 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ringierimu/state-workflow", 3 | "type": "library", 4 | "description": "Laravel State Workflow provide tools for defining and managing workflows and activities with ease.", 5 | "keywords": [ 6 | "workflow", 7 | "state management", 8 | "state machine", 9 | "state workflow", 10 | "laravel" 11 | ], 12 | "homepage": "https://github.com/RingierIMU/state-workflow", 13 | "license": "MIT", 14 | "authors": [ 15 | { 16 | "name": "Norby Baruani", 17 | "email": "norbybaru@gmail.com", 18 | "role": "Developer" 19 | } 20 | ], 21 | "require": { 22 | "php": "^8.0", 23 | "illuminate/events": "^9.52|^10.0", 24 | "illuminate/support": "^9.52|^10.0", 25 | "symfony/event-dispatcher": "^5.1", 26 | "symfony/workflow": "^5.1", 27 | "symfony/property-access": "^5.1" 28 | }, 29 | "require-dev": { 30 | "funkjedi/composer-include-files": "^1.0", 31 | "mockery/mockery": "^1.3|^1.4.2", 32 | "orchestra/testbench": "^6.24|^7.0|^8.0", 33 | "phpunit/phpunit": "^9.5|^10.0" 34 | }, 35 | "extra": { 36 | "include_files": [ 37 | "tests/Fixtures/Helpers.php" 38 | ], 39 | "laravel": { 40 | "providers": [ 41 | "Ringierimu\\StateWorkflow\\StateWorkflowServiceProvider" 42 | ] 43 | } 44 | }, 45 | "autoload": { 46 | "psr-4": { 47 | "Ringierimu\\StateWorkflow\\": "src/" 48 | } 49 | }, 50 | "autoload-dev": { 51 | "psr-4": { 52 | "Ringierimu\\StateWorkflow\\Tests\\": "tests/" 53 | } 54 | }, 55 | "minimum-stability": "dev", 56 | "prefer-stable": true, 57 | "config": { 58 | "allow-plugins": { 59 | "funkjedi/composer-include-files": true 60 | } 61 | }, 62 | "scripts": { 63 | "test": "phpunit" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /config/workflow.php: -------------------------------------------------------------------------------- 1 | [ 5 | /* 6 | |-------------------------------------------------------------------------- 7 | | User Providers 8 | |-------------------------------------------------------------------------- 9 | | 10 | | This define Authentication user is model of your application. 11 | | Ideally it should match your `providers.users.model` found in `config/auth.php` 12 | | to leverage the default Laravel auth resolver 13 | | 14 | */ 15 | 'user_class' => \App\User::class, 16 | ], 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Domain entity 21 | |-------------------------------------------------------------------------- 22 | | 23 | | This should be your model name in camelCase. 24 | | 25 | | eg. UserProfile::Class => userProfile 26 | | 27 | | Attributes definition 28 | | 29 | | subscriber: 30 | | Register subscriber for this workflow which contains business rules. 31 | | 32 | | property_path: 33 | | Attribute on your domain entity holding the actual state (default is "current_state") 34 | | 35 | | states: 36 | | Define all possible state your domain entity can transition to 37 | | 38 | | transitions: 39 | | Define all allowed transitions to transit from one state to another 40 | */ 41 | 'user' => [ 42 | // class of your domain object 43 | 'class' => \App\User::class, 44 | 45 | 'subscriber' => \App\Listeners\UserEventSubscriber::class, 46 | 47 | // Uncomment line below to override default attribute 48 | // 'property_path' => 'current_state', 49 | 50 | 'states' => [ 51 | 'new', 52 | 'pending_activation', 53 | 'activated', 54 | 'deleted', 55 | 'blocked', 56 | ], 57 | 58 | 'transitions' => [ 59 | 'create' => [ 60 | 'from' => 'new', 61 | 'to' => 'pending_activation', 62 | ], 63 | 'activate' => [ 64 | 'from' => 'pending_activation', 65 | 'to' => 'activated', 66 | ], 67 | 'block' => [ 68 | 'from' => ['pending_activation', 'activated'], 69 | 'to' => 'blocked', 70 | ], 71 | 'delete' => [ 72 | 'from' => ['pending_activation', 'activated', 'blocked'], 73 | 'to' => 'deleted', 74 | ], 75 | ], 76 | ], 77 | ]; 78 | -------------------------------------------------------------------------------- /database/migrations/create_state_workflow_histories_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->string('model_name')->index(); 19 | $table->integer('model_id')->index(); 20 | $table->string('transition'); 21 | $table->string('from'); 22 | $table->string('to'); 23 | $table->integer('user_id')->index(); 24 | $table->json('context'); 25 | $table->timestamps(); 26 | }); 27 | } 28 | 29 | /** 30 | * Reverse the migrations. 31 | */ 32 | public function down() 33 | { 34 | Schema::dropIfExists('state_workflow_histories'); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/Console/Commands/StateWorkflowDumpCommand.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | class StateWorkflowDumpCommand extends Command 21 | { 22 | /** 23 | * The name and signature of the console command. 24 | * 25 | * @var string 26 | */ 27 | protected $signature = 'workflow:dump 28 | {workflow : name of workflow from configuration} 29 | {--format=png : the image format}'; 30 | 31 | /** 32 | * The console command description. 33 | * 34 | * @var string 35 | */ 36 | protected $description = 'Dumps a State Workflow as a graphviz file using GraphvizDumper.'; 37 | 38 | /** 39 | * @throws \ReflectionException 40 | * @throws Exception 41 | */ 42 | public function handle() 43 | { 44 | $workflowName = $this->argument('workflow'); 45 | $format = $this->option('format'); 46 | $config = Config::get('workflow'); 47 | 48 | if (!isset($config[$workflowName])) { 49 | throw new Exception( 50 | "Workflow $workflowName is not configured. Make sure it is configured correctly on the config file." 51 | ); 52 | } 53 | 54 | if (!$config[$workflowName]['class']) { 55 | throw new Exception("Workflow $workflowName has no class"); 56 | } 57 | 58 | $class = $config[$workflowName]['class']; 59 | 60 | $ref = new \ReflectionClass($class); 61 | $model = $ref->newInstance(); 62 | 63 | if (!method_exists($model, 'workflow')) { 64 | throw new Exception( 65 | "Class $class does not support State Workflow. Make sure Class is configured correctly" 66 | ); 67 | } 68 | 69 | /** @var StateWorkflow $workflow */ 70 | $workflow = $model->workflow(); 71 | $definition = $workflow->getDefinition(); 72 | 73 | $dumper = new GraphvizDumper(); 74 | 75 | $dotCommand = "dot -T$format -o $workflowName.$format"; 76 | 77 | $process = new Process($dotCommand); 78 | $process->setInput($dumper->dump($definition)); 79 | $process->mustRun(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Events/BaseEvent.php: -------------------------------------------------------------------------------- 1 | originalEvent = $event; 25 | } 26 | 27 | /** 28 | * Return the original event. 29 | * 30 | * @return Event 31 | */ 32 | public function getOriginalEvent() 33 | { 34 | return $this->originalEvent; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Events/CompletedEvent.php: -------------------------------------------------------------------------------- 1 | originalEvent = $event; 22 | } 23 | 24 | /** 25 | * @return \Symfony\Component\Workflow\Event\Event|SymfonyGuardEvent 26 | */ 27 | public function getOriginalEvent() 28 | { 29 | return $this->originalEvent; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Events/LeaveEvent.php: -------------------------------------------------------------------------------- 1 | listen( 14 | * "Ringierimu\StateWorkflow\Events\GuardEvent", 15 | * "App\Listeners\UserEventSubscriber@onGuard" 16 | * ); 17 | * 18 | * $event->listen( 19 | * "workflow.user.guard.activate", 20 | * "App\Listeners\UserEventSubscriber@onGuardActivate" 21 | * ); 22 | * 23 | * @param \Illuminate\Events\Dispatcher $event 24 | */ 25 | public function subscribe($event); 26 | } 27 | -------------------------------------------------------------------------------- /src/Interfaces/WorkflowRegistryInterface.php: -------------------------------------------------------------------------------- 1 | 'array', 37 | ]; 38 | 39 | /** 40 | * @return \Illuminate\Database\Eloquent\Relations\MorphTo 41 | */ 42 | public function model() 43 | { 44 | return $this->morphTo(); 45 | } 46 | 47 | /** 48 | * @return \Illuminate\Database\Eloquent\Relations\BelongsTo 49 | */ 50 | public function user() 51 | { 52 | return $this->belongsTo(config('workflow.setup.user_class')); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/StateWorkflowServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishConfig(); 20 | $this->publishDatabase(); 21 | $this->loadMigrations(); 22 | $this->registerCommands(); 23 | } 24 | 25 | /** 26 | * Register the application services... 27 | */ 28 | public function register() 29 | { 30 | $this->mergeConfigFrom($this->configPath(), 'workflow'); 31 | 32 | $this->app->singleton('stateWorkflow', function () { 33 | return new WorkflowRegistry( 34 | collect($this->app['config']->get('workflow')) 35 | ->except('setup') 36 | ->toArray() 37 | ); 38 | }); 39 | 40 | $this->app->alias('stateWorkflow', WorkflowRegistryInterface::class); 41 | } 42 | 43 | /** 44 | * Return config file. 45 | * 46 | * @return string 47 | */ 48 | protected function configPath() 49 | { 50 | return __DIR__ . '/../config/workflow.php'; 51 | } 52 | 53 | /** 54 | * Return migrations path. 55 | * 56 | * @return string 57 | */ 58 | private function migrationPath() 59 | { 60 | return __DIR__ . '/../database/migrations'; 61 | } 62 | 63 | /** 64 | * Publish config file. 65 | */ 66 | protected function publishConfig() 67 | { 68 | if ($this->app->runningInConsole()) { 69 | $this->publishes([ 70 | $this->configPath() => config_path('workflow.php'), 71 | ], 'state-workflow-config'); 72 | } 73 | } 74 | 75 | protected function publishDatabase() 76 | { 77 | if ($this->app->runningInConsole()) { 78 | $path = 'migrations/' . date('Y_m_d_His', time()); 79 | $this->publishes([ 80 | $this->migrationPath() . '/create_state_workflow_histories_table.php' => database_path($path . '_create_state_workflow_histories_table.php'), 81 | ], 'state-workflow-migrations'); 82 | } 83 | } 84 | 85 | /** 86 | * Load migration files. 87 | */ 88 | protected function loadMigrations() 89 | { 90 | if ($this->app->runningInConsole()) { 91 | $this->loadMigrationsFrom($this->migrationPath()); 92 | } 93 | } 94 | 95 | /** 96 | * Register Artisan commands. 97 | */ 98 | protected function registerCommands() 99 | { 100 | $this->commands([ 101 | StateWorkflowDumpCommand::class, 102 | ]); 103 | } 104 | 105 | /** 106 | * Get the services provided by the provider. 107 | * 108 | * @return array 109 | */ 110 | public function provides() 111 | { 112 | return ['stateWorkflow']; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/Subscribers/WorkflowSubscriber.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | class WorkflowSubscriber implements EventSubscriberInterface 21 | { 22 | /** 23 | * Validate whether the transition is allowed at all. 24 | * 25 | * @param SymfonyGuardEvent $event 26 | */ 27 | public function guardEvent(SymfonyGuardEvent $event) 28 | { 29 | $workflowName = $event->getWorkflowName(); 30 | $transitionName = $event->getTransition()->getName(); 31 | 32 | $event = new GuardEvent($event); 33 | event('workflow.guard', $event); 34 | event(sprintf('workflow.%s.guard', $workflowName), $event); 35 | event(sprintf('workflow.%s.guard.%s', $workflowName, $transitionName), $event); 36 | } 37 | 38 | /** 39 | * The subject is about to leave a place. 40 | * 41 | * @param Event $event 42 | */ 43 | public function leaveEvent(Event $event) 44 | { 45 | $places = $event->getTransition()->getFroms(); 46 | $workflowName = $event->getWorkflowName(); 47 | 48 | $event = new LeaveEvent($event); 49 | event('workflow.leave', $event); 50 | event(sprintf('workflow.%s.leave', $workflowName), $event); 51 | 52 | foreach ($places as $place) { 53 | event(sprintf('workflow.%s.leave.%s', $workflowName, $place), $event); 54 | } 55 | } 56 | 57 | /** 58 | * The subject is going through this transition. 59 | * 60 | * @param Event $event 61 | */ 62 | public function transitionEvent(Event $event) 63 | { 64 | $workflowName = $event->getWorkflowName(); 65 | $transitionName = $event->getTransition()->getName(); 66 | 67 | $event = new TransitionEvent($event); 68 | event('workflow.transition', $event); 69 | event(sprintf('workflow.%s.transition', $workflowName), $event); 70 | event(sprintf('workflow.%s.transition.%s', $workflowName, $transitionName), $event); 71 | } 72 | 73 | /** 74 | * The subject is about to enter a new place. This event is triggered just before the subject places are updated, 75 | * which means that the marking of the subject is not yet updated with the new places. 76 | * 77 | * @param Event $event 78 | */ 79 | public function enterEvent(Event $event) 80 | { 81 | $places = $event->getTransition()->getTos(); 82 | $workflowName = $event->getWorkflowName(); 83 | 84 | $event = new EnterEvent($event); 85 | event('workflow.enter', $event); 86 | event(sprintf('workflow.%s.enter', $workflowName), $event); 87 | 88 | foreach ($places as $place) { 89 | event(sprintf('workflow.%s.enter.%s', $workflowName, $place), $event); 90 | } 91 | } 92 | 93 | /** 94 | * The subject has entered in the places and the marking is updated (making it a good place to flush data in Doctrine). 95 | * 96 | * @param Event $event 97 | */ 98 | public function enteredEvent(Event $event) 99 | { 100 | $places = $event->getTransition()->getTos(); 101 | $workflowName = $event->getWorkflowName(); 102 | 103 | $from = implode(',', $event->getTransition()->getFroms()); 104 | $to = implode(',', $event->getTransition()->getTos()); 105 | $model = $event->getSubject(); 106 | $model->save(); 107 | 108 | if (method_exists($model, 'saveStateHistory')) { 109 | $model->saveStateHistory([ 110 | 'transition' => $event->getTransition()->getName(), 111 | 'from' => $from, 112 | 'to' => $to, 113 | 'context' => method_exists($model, 'getContext') ? $model->getContext() : [], 114 | ]); 115 | } 116 | 117 | $event = new EnteredEvent($event); 118 | event('workflow.entered', $event); 119 | event(sprintf('workflow.%s.entered', $workflowName), $event); 120 | 121 | foreach ($places as $place) { 122 | event(sprintf('workflow.%s.entered.%s', $workflowName, $place), $event); 123 | } 124 | } 125 | 126 | /** 127 | * The object has completed this transition. 128 | * 129 | * @param Event $event 130 | */ 131 | public function completedEvent(Event $event) 132 | { 133 | $workflowName = $event->getWorkflowName(); 134 | $transitionName = $event->getTransition()->getName(); 135 | 136 | $event = new CompletedEvent($event); 137 | event('workflow.completed', $event); 138 | event(sprintf('workflow.%s.completed', $workflowName), $event); 139 | event(sprintf('workflow.%s.completed.%s', $workflowName, $transitionName), $event); 140 | } 141 | 142 | /** 143 | * Returns an array of event names this subscriber wants to listen to. 144 | * 145 | * The array keys are event names and the value can be: 146 | * 147 | * * The method name to call (priority defaults to 0) 148 | * * An array composed of the method name to call and the priority 149 | * * An array of arrays composed of the method names to call and respective 150 | * priorities, or 0 if unset 151 | * 152 | * For instance: 153 | * 154 | * * array('eventName' => 'methodName') 155 | * * array('eventName' => array('methodName', $priority)) 156 | * * array('eventName' => array(array('methodName1', $priority), array('methodName2'))) 157 | * 158 | * @return array The event names to listen to 159 | */ 160 | public static function getSubscribedEvents() 161 | { 162 | return [ 163 | 'workflow.guard' => ['guardEvent'], 164 | 'workflow.leave' => ['leaveEvent'], 165 | 'workflow.transition' => ['transitionEvent'], 166 | 'workflow.enter' => ['enterEvent'], 167 | 'workflow.entered' => ['enteredEvent'], 168 | 'workflow.completed' => ['completedEvent'], 169 | ]; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/Subscribers/WorkflowSubscriberHandler.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | abstract class WorkflowSubscriberHandler implements WorkflowEventSubscriberInterface 15 | { 16 | /** @var null */ 17 | protected $name; 18 | 19 | /** 20 | * WorkflowSubscriberHandler constructor. 21 | * 22 | * @param $workflowName 23 | */ 24 | public function __construct($workflowName = null) 25 | { 26 | $this->name = $workflowName; 27 | } 28 | 29 | /** 30 | * Register the listeners for the subscriber. 31 | * 32 | * $event->listen( 33 | * "Ringierimu\StateWorkflow\Events\GuardEvent", 34 | * "App\Listeners\UserEventSubscriber@onGuard" 35 | * ); 36 | * 37 | * $event->listen( 38 | * "workflow.user.guard.activate", 39 | * "App\Listeners\UserEventSubscriber@onGuardActivate" 40 | * ); 41 | * 42 | * @param \Illuminate\Events\Dispatcher $event 43 | */ 44 | public function subscribe($event) 45 | { 46 | // get name of instantiated concrete class 47 | $class = get_called_class(); 48 | // loop through each method of the class 49 | foreach (get_class_methods($class) as $method) { 50 | // if the method name starts with 'on' 51 | if (preg_match('/^on/', $method)) { 52 | // attach the event listener 53 | $event->listen($this->key($method), $class . '@' . $method); 54 | } 55 | } 56 | } 57 | 58 | /** 59 | * Generate event key from Subscriber method to match workflow event dispatcher names. 60 | * 61 | * Format on how to register method to listen to specific workflow events. 62 | * 63 | * eg. 64 | * 1. on[Event] - onGuard 65 | * 2. on[Event][Transition/State name] - onGuardActivate 66 | * 67 | * NB: 68 | * - Guard and Transition event uses of transition name 69 | * - Leave, Enter and Entered event uses state name 70 | * 71 | * ******* Fired Events ********* 72 | * - Guard Event 73 | * workflow.guard 74 | * workflow.[workflow name].guard 75 | * workflow.[workflow name].guard.[transition name] 76 | * 77 | * - Leave Event 78 | * workflow.leave 79 | * workflow.[workflow name].leave 80 | * workflow.[workflow name].leave.[state name] 81 | * 82 | * - Transition Event 83 | * workflow.transition 84 | * workflow.[workflow name].transition 85 | * workflow.[workflow name].transition.[transition name] 86 | * 87 | * - Enter Event 88 | * workflow.enter 89 | * workflow.[workflow name].enter 90 | * workflow.[workflow name].enter.[state name] 91 | * 92 | * - Entered Event 93 | * workflow.entered 94 | * workflow.[workflow name].entered 95 | * workflow.[workflow name].entered.[state name] 96 | * 97 | * @param $name 98 | * 99 | * @return string 100 | */ 101 | protected function key($name) 102 | { 103 | // remove on from beginning. eg. onGuard => Guard 104 | $name = ltrim($name, 'on'); 105 | // prepend uppercase letters with . eg. EnteredDeleted => .Entered.Deleted 106 | $name = preg_replace_callback('/[A-Z]/', function ($m) { 107 | return ".{$m[0]}"; 108 | }, $name); 109 | // remove trailing . eg. .Entered.Deleted => Entered.Deleted 110 | $name = ltrim($name, '.'); 111 | // now that we have the dots we can lowercase the name. eg. Entered.Deleted => entered.deleted 112 | $name = strtolower($name); 113 | 114 | $segments = explode('.', $name); 115 | 116 | // add underscore for transition with underscore name 117 | if (count($segments) > 2) { 118 | $transition = $segments[0]; 119 | unset($segments[0]); 120 | $flow = implode('_', $segments); 121 | $name = $transition . '.' . $flow; 122 | } 123 | 124 | return sprintf('workflow.%s.%s', $this->name, $name); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Traits/HasWorkflowTrait.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | trait HasWorkflowTrait 15 | { 16 | /** @var StateWorkflow */ 17 | protected $workflow; 18 | 19 | /** 20 | * @var array 21 | */ 22 | protected $context = []; 23 | 24 | /** 25 | * Model to save model change history from one state to another. 26 | * 27 | * @var string 28 | */ 29 | private $stateHistoryModel = StateWorkflowHistory::class; 30 | 31 | /** 32 | * @throws \ReflectionException 33 | * 34 | * @return StateWorkflow 35 | */ 36 | public function workflow() 37 | { 38 | if (!$this->workflow) { 39 | $this->workflow = app(WorkflowRegistryInterface::class)->get($this, $this->configName()); 40 | } 41 | 42 | return $this->workflow; 43 | } 44 | 45 | /** 46 | * @throws \ReflectionException 47 | * 48 | * @return mixed 49 | */ 50 | public function state() 51 | { 52 | return $this->workflow()->getState($this); 53 | } 54 | 55 | /** 56 | * @param $transition 57 | * @param array $context 58 | * 59 | * @throws \ReflectionException 60 | * 61 | * @return \Symfony\Component\Workflow\Marking 62 | */ 63 | public function applyTransition($transition, $context = []) 64 | { 65 | $this->context = $context; 66 | 67 | return $this->workflow()->apply($this, $transition); 68 | } 69 | 70 | /** 71 | * @param $transition 72 | * 73 | * @throws \ReflectionException 74 | * 75 | * @return bool 76 | */ 77 | public function canTransition($transition) 78 | { 79 | return $this->workflow()->can($this, $transition); 80 | } 81 | 82 | /** 83 | * Return object available transitions. 84 | * 85 | * @throws \ReflectionException 86 | * 87 | * @return array|\Symfony\Component\Workflow\Transition[] 88 | */ 89 | public function getEnabledTransition() 90 | { 91 | return $this->workflow()->getEnabledTransitions($this); 92 | } 93 | 94 | /** 95 | * @return \Illuminate\Database\Eloquent\Relations\HasMany 96 | */ 97 | public function stateHistory() 98 | { 99 | return $this->morphMany($this->stateHistoryModel, 'model', 'model_name', null, $this->modelPrimaryKey()); 100 | } 101 | 102 | /** 103 | * Save Model changes and log changes to StateHistory table. 104 | * 105 | * @param array $transitionData 106 | * 107 | * @return \Illuminate\Database\Eloquent\Model 108 | */ 109 | public function saveStateHistory(array $transitionData) 110 | { 111 | $transitionData[$this->authUserForeignKey()] = $this->authenticatedUserId(); 112 | 113 | return $this->stateHistory()->create($transitionData); 114 | } 115 | 116 | /** 117 | * Return authenticated user id. 118 | * 119 | * @return int|null 120 | */ 121 | public function authenticatedUserId() 122 | { 123 | return auth()->id(); 124 | } 125 | 126 | /** 127 | * Model configuration name on config/workflow.php. 128 | * 129 | * @throws \ReflectionException 130 | * 131 | * @return string 132 | */ 133 | public function configName() 134 | { 135 | return lcfirst((new \ReflectionClass($this))->getShortName()); 136 | } 137 | 138 | /** 139 | * @return string 140 | */ 141 | public function authUserForeignKey() 142 | { 143 | return 'user_id'; 144 | } 145 | 146 | /** 147 | * @return string 148 | */ 149 | public function modelPrimaryKey() 150 | { 151 | return 'id'; 152 | } 153 | 154 | /** 155 | * @return array 156 | */ 157 | public function getContext() 158 | { 159 | return $this->context; 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/Workflow/MethodMarkingStore.php: -------------------------------------------------------------------------------- 1 | getProperty()` 24 | * The `setMarking` method will use `$subject->setProperty(string|array $places, array $context = array())` 25 | */ 26 | public function __construct(bool $singleState = false, string $property = 'marking') 27 | { 28 | $this->singleState = $singleState; 29 | $this->property = $property; 30 | $this->propertyAccessor = PropertyAccess::createPropertyAccessor(); 31 | } 32 | 33 | /** 34 | * {@inheritdoc} 35 | */ 36 | public function getMarking(object $subject): Marking 37 | { 38 | $marking = $this->propertyAccessor->getValue($subject, $this->property); 39 | 40 | if (null === $marking) { 41 | return new Marking(); 42 | } 43 | 44 | if ($this->singleState) { 45 | $marking = [(string) $marking => 1]; 46 | } 47 | 48 | return new Marking($marking); 49 | } 50 | 51 | /** 52 | * {@inheritdoc} 53 | */ 54 | public function setMarking(object $subject, Marking $marking, array $context = []) 55 | { 56 | $this->propertyAccessor->setValue($subject, $this->property, key($marking->getPlaces())); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Workflow/StateWorkflow.php: -------------------------------------------------------------------------------- 1 | config = $config; 38 | } 39 | 40 | /** 41 | * Returns the current state. 42 | * 43 | * @param $object 44 | * 45 | * @return mixed 46 | */ 47 | public function getState($object) 48 | { 49 | $accessor = new PropertyAccessor(); 50 | $propertyPath = isset($this->config['property_path']) ? $this->config['property_path'] : 'current_state'; 51 | 52 | return $accessor->getValue($object, $propertyPath); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/WorkflowRegistry.php: -------------------------------------------------------------------------------- 1 | registry = new Registry(); 54 | $this->config = $config; 55 | $this->dispatcher = new EventDispatcher(); 56 | 57 | $subscriber = new WorkflowSubscriber(); 58 | $this->dispatcher->addSubscriber($subscriber); 59 | 60 | foreach ($this->config as $name => $workflowData) { 61 | $this->addWorkflows($name, $workflowData); 62 | if (array_key_exists('subscriber', $workflowData)) { 63 | $this->addSubscriber($workflowData['subscriber'], $name); 64 | } 65 | } 66 | } 67 | 68 | /** 69 | * {@inheritdoc} 70 | */ 71 | public function get($subject, $workflowName = null) 72 | { 73 | return $this->registry->get($subject, $workflowName); 74 | } 75 | 76 | /** 77 | * Add a workflow to the subject. 78 | * 79 | * @param StateWorkflow $workflow 80 | * @param string $className 81 | */ 82 | public function registerWorkflow(StateWorkflow $workflow, string $className) 83 | { 84 | // Add method became addWorkflow method in Symfony Workflow Component v4.1 85 | // InstanceOfSupportStrategy class became ClassInstanceSupportStrategy in v4.1 86 | $method = method_exists($this->registry, 'addWorkflow') ? 'addWorkflow' : 'add'; 87 | $strategyClass = class_exists(InstanceOfSupportStrategy::class) 88 | ? InstanceOfSupportStrategy::class 89 | : ClassInstanceSupportStrategy::class; 90 | $this->registry->$method($workflow, new $strategyClass($className)); 91 | } 92 | 93 | /** 94 | * Add a workflow to the registry from array. 95 | * 96 | * @param $name 97 | * @param array $workflowData 98 | * 99 | * @throws \ReflectionException 100 | */ 101 | public function addWorkflows($name, array $workflowData) 102 | { 103 | $definitionBuilder = new DefinitionBuilder($workflowData['states']); 104 | 105 | foreach ($workflowData['transitions'] as $transitionName => $transition) { 106 | if (!is_string($transitionName)) { 107 | $transitionName = $transition['name']; 108 | } 109 | 110 | foreach ((array) $transition['from'] as $form) { 111 | $definitionBuilder->addTransition(new Transition($transitionName, $form, $transition['to'])); 112 | } 113 | } 114 | 115 | $definition = $definitionBuilder->build(); 116 | $markingStore = $this->getMarkingStoreInstance($workflowData); 117 | $workflow = $this->getWorkflowInstance($name, $workflowData, $definition, $markingStore); 118 | 119 | $this->registerWorkflow($workflow, $workflowData['class']); 120 | } 121 | 122 | /** 123 | * Return the workflow instance. 124 | * 125 | * @param $name 126 | * @param array $workflowData 127 | * @param Definition $definition 128 | * @param MarkingStoreInterface $markingStore 129 | * 130 | * @return mixed 131 | */ 132 | protected function getWorkflowInstance( 133 | $name, 134 | array $workflowData, 135 | Definition $definition, 136 | MarkingStoreInterface $markingStore 137 | ) { 138 | $className = $this->getWorkflowClass($workflowData); 139 | 140 | return new $className($workflowData, $definition, $markingStore, $this->dispatcher, $name); 141 | } 142 | 143 | /** 144 | * @param array $workflowData 145 | * @param bool $override 146 | * 147 | * @return mixed|string 148 | */ 149 | private function getWorkflowClass(array $workflowData, $override = true) 150 | { 151 | if ($override) { 152 | $className = StateWorkflow::class; 153 | } elseif (isset($workflowData['type']) && $workflowData['type'] === 'state_machine') { 154 | $className = StateMachine::class; 155 | } else { 156 | $className = Workflow::class; 157 | } 158 | 159 | return $className; 160 | } 161 | 162 | /** 163 | * Return the making store instance. 164 | * 165 | * @param array $workflowData 166 | * 167 | * @throws \ReflectionException 168 | * 169 | * @return object|MarkingStoreInterface 170 | */ 171 | protected function getMarkingStoreInstance(array $workflowData) 172 | { 173 | $markingStoreData = isset($workflowData['marking_store']) ? $workflowData['marking_store'] : []; 174 | $propertyPath = isset($workflowData['property_path']) ? $workflowData['property_path'] : 'current_state'; 175 | 176 | $singleState = true; 177 | 178 | if (isset($markingStoreData['type']) && $markingStoreData['type'] === 'multiple_state') { 179 | $singleState = false; // true if the subject can be in only one state at a given time 180 | } 181 | 182 | if (isset($markingStoreData['class'])) { 183 | $className = $markingStoreData['class']; 184 | } else { 185 | $className = MethodMarkingStore::class; 186 | } 187 | 188 | $arguments = [$singleState, $propertyPath]; 189 | $class = new ReflectionClass($className); 190 | 191 | return $class->newInstanceArgs($arguments); 192 | } 193 | 194 | /** 195 | * Register workflow subscribers. 196 | * 197 | * @param $class 198 | * @param $name 199 | * 200 | * @throws \ReflectionException 201 | * @throws Exception 202 | */ 203 | public function addSubscriber($class, $name) 204 | { 205 | $reflection = new ReflectionClass($class); 206 | 207 | if (!$reflection->implementsInterface(WorkflowEventSubscriberInterface::class)) { 208 | throw new Exception("$class must implements " . WorkflowEventSubscriberInterface::class); 209 | } 210 | 211 | if ($reflection->isInstantiable()) { 212 | Event::subscribe($reflection->newInstance($name)); 213 | } 214 | } 215 | } 216 | --------------------------------------------------------------------------------