├── .github ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── config.yml ├── dependabot.yml └── workflows │ ├── php-cs-fixer.yml │ └── run-tests.yml ├── .gitignore ├── .php_cs.dist.php ├── LICENSE ├── README.md ├── composer.json ├── config ├── workflow.php └── workflow_registry.php ├── phpunit.xml ├── src ├── Commands │ └── WorkflowDumpCommand.php ├── Events │ ├── AnnounceEvent.php │ ├── BaseEvent.php │ ├── CompletedEvent.php │ ├── DispatcherAdapter.php │ ├── EnterEvent.php │ ├── EnteredEvent.php │ ├── GuardEvent.php │ ├── LeaveEvent.php │ ├── TransitionEvent.php │ └── WorkflowEvent.php ├── Exceptions │ ├── DuplicateWorkflowException.php │ └── RegistryNotTrackedException.php ├── Facades │ └── WorkflowFacade.php ├── MarkingStores │ └── EloquentMarkingStore.php ├── Traits │ └── WorkflowTrait.php ├── WorkflowRegistry.php └── WorkflowServiceProvider.php └── tests ├── BaseWorkflowTestCase.php ├── DispatchAdapterTest.php ├── EventTest.php ├── Fixtures ├── TestCustomObject.php ├── TestEloquentModel.php ├── TestModel.php ├── TestModelMutator.php ├── TestObject.php └── TestWorkflowListener.php ├── Helpers └── CanAccessProtected.php ├── MarkingStores └── EloquentMarkingStoreTest.php ├── WorkflowDumpCommandTest.php ├── WorkflowEventsTest.php ├── WorkflowMetadataTest.php ├── WorkflowRegistryTest.php ├── WorkflowTrackingTest.php └── WorkflowTraitTest.php /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | Please read and understand the contribution guide before creating an issue or pull request. 6 | 7 | ## Etiquette 8 | 9 | This project is open source, and as such, the maintainers give their free time to build and maintain the source code 10 | held within. They make the code freely available in the hope that it will be of use to other developers. It would be 11 | extremely unfair for them to suffer abuse or anger for their hard work. 12 | 13 | Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the 14 | world that developers are civilized and selfless people. 15 | 16 | It's the duty of the maintainer to ensure that all submissions to the project are of sufficient 17 | quality to benefit the project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used. 18 | 19 | ## Viability 20 | 21 | When requesting or submitting new features, first consider whether it might be useful to others. Open 22 | source projects are used by many developers, who may have entirely different needs to your own. Think about 23 | whether or not your feature is likely to be used by other users of the project. 24 | 25 | ## Procedure 26 | 27 | Before filing an issue: 28 | 29 | - Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. 30 | - Check to make sure your feature suggestion isn't already present within the project. 31 | - Check the pull requests tab to ensure that the bug doesn't have a fix in progress. 32 | - Check the pull requests tab to ensure that the feature isn't already in progress. 33 | 34 | Before submitting a pull request: 35 | 36 | - Check the codebase to ensure that your feature doesn't already exist. 37 | - Check the pull requests to ensure that another person hasn't already submitted the feature or fix. 38 | 39 | ## Requirements 40 | 41 | If the project maintainer has any additional requirements, you will find them listed here. 42 | 43 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](https://pear.php.net/package/PHP_CodeSniffer). 44 | 45 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 46 | 47 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 48 | 49 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](https://semver.org/). Randomly breaking public APIs is not an option. 50 | 51 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 52 | 53 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](https://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 54 | 55 | **Happy coding**! 56 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: zerodahero 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Ask a question 4 | url: https://github.com/zerodahero/laravel-workflow/discussions/new?category=q-a 5 | about: Ask the community for help 6 | - name: Request a feature 7 | url: https://github.com/zerodahero/laravel-workflow/discussions/new?category=ideas 8 | about: Share ideas for new features 9 | - name: Report a security issue 10 | url: https://github.com/zerodahero/laravel-workflow/security/policy 11 | about: Learn how to notify us for sensitive bugs 12 | - name: Report a bug 13 | url: https://github.com/zerodahero/laravel-workflow/issues/new 14 | about: Report a reproducable bug 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | labels: 12 | - "dependencies" -------------------------------------------------------------------------------- /.github/workflows/php-cs-fixer.yml: -------------------------------------------------------------------------------- 1 | name: Check & fix styling 2 | 3 | on: [push] 4 | 5 | jobs: 6 | php-cs-fixer: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v4 12 | with: 13 | ref: ${{ github.head_ref }} 14 | 15 | - name: Run PHP CS Fixer 16 | uses: docker://oskarstark/php-cs-fixer-ga 17 | with: 18 | args: --config=.php_cs.dist.php --allow-risky=yes 19 | 20 | - name: Commit changes 21 | uses: stefanzweifel/git-auto-commit-action@v5 22 | with: 23 | commit_message: Fix styling 24 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit Test 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | test: 9 | runs-on: ${{ matrix.os }} 10 | 11 | strategy: 12 | fail-fast: true 13 | matrix: 14 | os: [ubuntu-latest, windows-latest] 15 | php: [8.1, 8.2, 8.3] 16 | laravel: ['10.*', '11.*', '12.*'] 17 | stability: [prefer-stable] 18 | exclude: 19 | - laravel: 11.* 20 | php: 8.1 21 | - laravel: 12.* 22 | php: 8.1 23 | 24 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} 25 | 26 | steps: 27 | - name: Checkout code 28 | uses: actions/checkout@v4 29 | 30 | - name: Setup Graphviz 31 | uses: ts-graphviz/setup-graphviz@v2 32 | 33 | - name: Setup PHP 34 | uses: shivammathur/setup-php@v2 35 | with: 36 | php-version: ${{ matrix.php }} 37 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo 38 | coverage: none 39 | 40 | - name: Setup problem matchers 41 | run: | 42 | echo "::add-matcher::${{ runner.tool_cache }}/php.json" 43 | echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" 44 | 45 | - name: Install dependencies 46 | run: | 47 | composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update 48 | composer update --${{ matrix.stability }} --prefer-dist --no-interaction 49 | 50 | - name: Execute tests 51 | run: vendor/bin/phpunit 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .php_cs 3 | .php_cs.cache 4 | .phpunit.result.cache 5 | .vscode 6 | composer.lock 7 | tests/coverage 8 | docs 9 | vendor 10 | .php-cs-fixer.cache 11 | -------------------------------------------------------------------------------- /.php_cs.dist.php: -------------------------------------------------------------------------------- 1 | exclude('bootstrap/cache') 5 | ->exclude('storage') 6 | ->exclude('vendor') 7 | ->exclude('bower_components') 8 | ->exclude('node_modules') 9 | ->in(__DIR__) 10 | ->name('*.php') 11 | ->notName('*.blade.php') 12 | ->ignoreDotFiles(true) 13 | ->ignoreVCS(true); 14 | 15 | $config = new PhpCsFixer\Config(); 16 | 17 | return $config->setFinder($finder) 18 | ->setRules([ 19 | '@PSR2' => true, 20 | 'phpdoc_no_empty_return' => false, 21 | 'phpdoc_var_annotation_correct_order' => true, 22 | 'array_syntax' => [ 23 | 'syntax' => 'short', 24 | ], 25 | 'no_singleline_whitespace_before_semicolons' => true, 26 | 'no_extra_blank_lines' => [ 27 | 'tokens' => [ 28 | 'break', 'case', 'continue', 'curly_brace_block', 'default', 29 | 'extra', 'parenthesis_brace_block', 'return', 30 | 'square_brace_block', 'switch', 'throw', 'use', 31 | ], 32 | ], 33 | 'cast_spaces' => [ 34 | 'space' => 'single', 35 | ], 36 | 'concat_space' => [ 37 | 'spacing' => 'one', 38 | ], 39 | 'ordered_imports' => [ 40 | 'sort_algorithm' => 'length', 41 | ], 42 | 'single_quote' => true, 43 | 'lowercase_cast' => true, 44 | 'lowercase_static_reference' => true, 45 | 'no_empty_phpdoc' => true, 46 | 'no_empty_comment' => true, 47 | 'array_indentation' => true, 48 | // TODO: This isn't working, causes fixer to error. 49 | // 'increment_style' => ['style' => 'post'], 50 | 'short_scalar_cast' => true, 51 | 'class_attributes_separation' => [ 52 | 'elements' => ['const' => 'one', 'method' => 'one', 'property' => 'one', 'trait_import' => 'none'], 53 | ], 54 | 'no_mixed_echo_print' => [ 55 | 'use' => 'echo', 56 | ], 57 | 'no_unused_imports' => true, 58 | 'binary_operator_spaces' => [ 59 | 'default' => 'single_space', 60 | ], 61 | 'no_empty_statement' => true, 62 | 'unary_operator_spaces' => true, // $number ++ becomes $number++ 63 | 'single_line_comment_style' => ['comment_types' => ['hash']], // # becomes // 64 | 'standardize_not_equals' => true, // <> becomes != 65 | 'native_function_casing' => true, 66 | 'ternary_operator_spaces' => true, 67 | 'ternary_to_null_coalescing' => true, 68 | 'declare_equal_normalize' => [ 69 | 'space' => 'single', 70 | ], 71 | 'function_typehint_space' => true, 72 | 'no_leading_import_slash' => true, 73 | 'blank_line_before_statement' => [ 74 | 'statements' => [ 75 | 'break', 'case', 'continue', 76 | 'declare', 'default', 'exit', 77 | 'do', 'for', 'foreach', 'goto', 78 | 'if', 'include', 'include_once', 79 | 'require', 'require_once', 'return', 80 | 'switch', 'throw', 'try', 'while', 'yield', 81 | ], 82 | ], 83 | 'combine_consecutive_unsets' => true, 84 | 'method_chaining_indentation' => true, 85 | 'no_whitespace_in_blank_line' => true, 86 | 'blank_line_after_opening_tag' => true, 87 | 'no_trailing_comma_in_list_call' => true, 88 | 'list_syntax' => ['syntax' => 'short'], 89 | // public function getTimezoneAttribute( ? Banana $value) becomes public function getTimezoneAttribute(?Banana $value) 90 | 'compact_nullable_typehint' => true, 91 | 'explicit_string_variable' => true, 92 | 'no_leading_namespace_whitespace' => true, 93 | 'trailing_comma_in_multiline' => true, 94 | 'not_operator_with_successor_space' => true, 95 | 'object_operator_without_whitespace' => true, 96 | 'single_blank_line_before_namespace' => true, 97 | 'no_blank_lines_after_class_opening' => true, 98 | 'no_blank_lines_after_phpdoc' => true, 99 | 'no_whitespace_before_comma_in_array' => true, 100 | 'no_trailing_comma_in_singleline_array' => true, 101 | 'multiline_whitespace_before_semicolons' => [ 102 | 'strategy' => 'no_multi_line', 103 | ], 104 | 'no_multiline_whitespace_around_double_arrow' => true, 105 | 'no_useless_return' => true, 106 | 'phpdoc_add_missing_param_annotation' => true, 107 | 'phpdoc_order' => true, 108 | 'phpdoc_scalar' => true, 109 | 'phpdoc_separation' => true, 110 | 'phpdoc_single_line_var_spacing' => true, 111 | 'single_trait_insert_per_statement' => true, 112 | 'ordered_class_elements' => [ 113 | 'order' => [ 114 | 'use_trait', 115 | 'constant', 116 | 'property', 117 | 'construct', 118 | 'phpunit', 119 | 'public', 120 | 'protected', 121 | 'private', 122 | ], 123 | 'sort_algorithm' => 'none', 124 | ], 125 | 'return_type_declaration' => [ 126 | 'space_before' => 'none', 127 | ], 128 | ]) 129 | ->setLineEnding("\n"); 130 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 BREXIS 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 workflow 2 | 3 | Use the Symfony Workflow component in Laravel 4 | 5 | ## Installation 6 | 7 | ```bash 8 | composer require zerodahero/laravel-workflow 9 | ``` 10 | 11 | ## Laravel Support 12 | 13 | | Package Version | Laravel Version Support | 14 | | --- | --- | 15 | | ^2.0 | 5.x | 16 | | ^3.0 | 7.x | 17 | | ^3.2 | 8.x | 18 | | ^4.0 | 9.x, 10.x | 19 | | ^5.0 | 10.x, 11.x | 20 | | ^6.0 | 10.x, 11.x | 21 | | ^6.1 | 10.x, 11.x, 12.x | 22 | 23 | ## Upgrade from v5 to v6 24 | 25 | Version 6 drops support for PHP 8.0 and Laravel 9. 26 | 27 | ## Upgrade from v4 to v5 28 | 29 | The primary change is to the PHP and Laravel version, which only PHP 8.0, 8.1, 8.2 and Laravel 9, 10, and 11 are supported in this version. 30 | 31 | ## Upgrade from v3 to v4 32 | 33 | The primary change is to the PHP and Laravel version, which only PHP 8.0, 8.1 and Laravel 9 are supported in this version. If you required to use the older version, use version 3.4. 34 | 35 | ## Upgrade from v2 to v3 36 | 37 | The biggest changes from v2 to v3 are the dependencies. To match the Symfony v5 components, the Laravel version is raised to v7. If you're on Laravel v6 or earlier, you should continue to use the v2 releases of this package. 38 | 39 | To match the changes in the Symfony v5 workflow component, the "arguments" config option has been changed to "property". This describes the property on the model the workflow ties to (in most circumstances, you can simply change the key name from "arguments" to "property", and set to a string instead of the previous array). 40 | 41 | Also, the "initial_place" key has been changed to "initial_places" to align with the Symfony component as well. 42 | 43 | ### Non-package Discovery 44 | 45 | If you aren't using package discovery: 46 | 47 | Add a ServiceProvider to your providers array in `config/app.php`: 48 | 49 | ```php 50 | [ 53 | ... 54 | ZeroDaHero\LaravelWorkflow\WorkflowServiceProvider::class, 55 | 56 | ] 57 | ``` 58 | 59 | Add the `Workflow` facade to your facades array: 60 | 61 | ```php 62 | ZeroDaHero\LaravelWorkflow\Facades\WorkflowFacade::class, 65 | ``` 66 | 67 | ## Configuration 68 | 69 | Publish the config file 70 | 71 | ```bash 72 | php artisan vendor:publish --provider="ZeroDaHero\LaravelWorkflow\WorkflowServiceProvider" 73 | ``` 74 | 75 | Configure your workflow in `config/workflow.php` 76 | 77 | ```php 78 | [ 84 | 'type' => 'workflow', // or 'state_machine', defaults to 'workflow' if omitted 85 | // The marking store can be omitted, and will default to 'multiple_state' 86 | // for workflow and 'single_state' for state_machine if the type is omitted 87 | 'marking_store' => [ 88 | 'property' => 'marking', // this is the property on the model, defaults to 'marking' 89 | 'class' => MethodMarkingStore::class, // optional, uses EloquentMethodMarkingStore by default (for Eloquent models) 90 | ], 91 | // optional top-level metadata 92 | 'metadata' => [ 93 | // any data 94 | ], 95 | 'supports' => ['App\BlogPost'], // objects this workflow supports 96 | // Specifies events to dispatch (only in 'workflow', not 'state_machine') 97 | // - set `null` to dispatch all events (default, if omitted) 98 | // - set to empty array (`[]`) to dispatch no events 99 | // - set to array of events to dispatch only specific events 100 | // Note that announce will dispatch a guard event on the next transition 101 | // (if announce isn't dispatched the next transition won't guard until checked/applied) 102 | 'events_to_dispatch' => [ 103 | Symfony\Component\Workflow\WorkflowEvents::ENTER, 104 | Symfony\Component\Workflow\WorkflowEvents::LEAVE, 105 | Symfony\Component\Workflow\WorkflowEvents::TRANSITION, 106 | Symfony\Component\Workflow\WorkflowEvents::ENTERED, 107 | Symfony\Component\Workflow\WorkflowEvents::COMPLETED, 108 | Symfony\Component\Workflow\WorkflowEvents::ANNOUNCE, 109 | ], 110 | 'places' => ['draft', 'review', 'rejected', 'published'], 111 | 'initial_places' => ['draft'], // defaults to the first place if omitted 112 | 'transitions' => [ 113 | 'to_review' => [ 114 | 'from' => 'draft', 115 | 'to' => 'review', 116 | // optional transition-level metadata 117 | 'metadata' => [ 118 | // any data 119 | ] 120 | ], 121 | 'publish' => [ 122 | 'from' => 'review', 123 | 'to' => 'published' 124 | ], 125 | 'reject' => [ 126 | 'from' => 'review', 127 | 'to' => 'rejected' 128 | ] 129 | ], 130 | ] 131 | ]; 132 | ``` 133 | 134 | A more minimal setup (for a workflow on an eloquent model). 135 | 136 | ```php 137 | [ 143 | 'supports' => ['App\BlogPost'], // objects this workflow supports 144 | 'places' => ['draft', 'review', 'rejected', 'published'], 145 | 'transitions' => [ 146 | 'to_review' => [ 147 | 'from' => 'draft', 148 | 'to' => 'review' 149 | ], 150 | 'publish' => [ 151 | 'from' => 'review', 152 | 'to' => 'published' 153 | ], 154 | 'reject' => [ 155 | 'from' => 'review', 156 | 'to' => 'rejected' 157 | ] 158 | ], 159 | ] 160 | ]; 161 | ``` 162 | 163 | If you are using a "multiple_state" type of workflow (i.e. you will be in multiple places simultaneously in your workflow), you will need your supported class/Eloquent model to cast the marking to an array. Read more in the [Laravel docs](https://laravel.com/docs/5.8/eloquent-mutators#array-and-json-casting). 164 | 165 | 166 | You may also add in metadata, similar to the Symfony implementation (note: it is not collected the same way as Symfony's implementation, but should work the same. Please open a pull request or issue if that's not the case.) 167 | 168 | ```php 169 | [ 173 | 'type' => 'workflow', // or 'state_machine' 174 | 'metadata' => [ 175 | 'title' => 'Blog Publishing Workflow', 176 | ], 177 | 'supports' => ['App\BlogPost'], 178 | 'places' => [ 179 | 'draft' => [ 180 | 'metadata' => [ 181 | 'max_num_of_words' => 500, 182 | ] 183 | ], 184 | 'review', 185 | 'rejected', 186 | 'published' 187 | ], 188 | 'transitions' => [ 189 | 'to_review' => [ 190 | 'from' => 'draft', 191 | 'to' => 'review', 192 | 'metadata' => [ 193 | 'priority' => 0.5, 194 | ] 195 | ], 196 | 'publish' => [ 197 | 'from' => 'review', 198 | 'to' => 'published' 199 | ], 200 | 'reject' => [ 201 | 'from' => 'review', 202 | 'to' => 'rejected' 203 | ] 204 | ], 205 | ] 206 | ]; 207 | ``` 208 | 209 | Use the `WorkflowTrait` inside supported classes 210 | 211 | ```php 212 | workflow_get(); 239 | // if more than one workflow is defined for the BlogPost class 240 | $workflow = $post->workflow_get($workflowName); 241 | 242 | $workflow->can($post, 'publish'); // False 243 | $workflow->can($post, 'to_review'); // True 244 | $transitions = $workflow->getEnabledTransitions($post); 245 | 246 | // Apply a transition 247 | $workflow->apply($post, 'to_review'); 248 | $post->save(); // Don't forget to persist the state 249 | 250 | // Get the workflow directly 251 | 252 | // Using the WorkflowTrait 253 | $post->workflow_can('publish'); // True 254 | $post->workflow_can('to_review'); // False 255 | 256 | // Get the post transitions 257 | foreach ($post->workflow_transitions() as $transition) { 258 | echo $transition->getName(); 259 | } 260 | 261 | // Apply a transition 262 | $post->workflow_apply('publish'); 263 | $post->save(); 264 | ``` 265 | 266 | ## Symfony Workflow Usage 267 | Once you have the underlying Symfony workflow component, you can do anything you want, just like you would in Symfony. A couple examples are provided below, but be sure to take a look at the [Symfony docs](https://symfony.com/doc/current/workflow.html) to better understand what's going on here. 268 | 269 | ```php 270 | workflow_get(); 277 | 278 | // Get the current places 279 | $places = $workflow->getMarking($post)->getPlaces(); 280 | 281 | // Get the definition 282 | $definition = $workflow->getDefinition(); 283 | 284 | // Get the metadata 285 | $metadata = $workflow->getMetadataStore(); 286 | // or get a specific piece of metadata 287 | $workflowMetadata = $workflow->getMetadataStore()->getWorkflowMetadata(); 288 | $placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place); // string place name 289 | $transitionMetadata = $workflow->getMetadataStore()->getTransitionMetadata($transition); // transition object 290 | // or by key 291 | $otherPlaceMetadata = $workflow->getMetadataStore()->getMetadata('max_num_of_words', 'draft'); 292 | ``` 293 | 294 | ### Use the events 295 | This package provides a list of events fired during a transition 296 | 297 | ```php 298 | ZeroDaHero\LaravelWorkflow\Events\Guard 299 | ZeroDaHero\LaravelWorkflow\Events\Leave 300 | ZeroDaHero\LaravelWorkflow\Events\Transition 301 | ZeroDaHero\LaravelWorkflow\Events\Enter 302 | ZeroDaHero\LaravelWorkflow\Events\Entered 303 | ``` 304 | 305 | You are encouraged to use [Symfony's dot syntax style of event emission](https://symfony.com/doc/current/workflow.html#using-events), as this provides the best level of precision for listening to events and prevents receiving the same event class multiple times for the "same" event. The workflow component dispatches multiple events per workflow event, and the translation into Laravel events can cause "duplicate" events to be listened to if you only listen by class name. 306 | 307 | NOTE: these events receive the Symfony event prior to version 3.1.1, and will receive this package's events starting with version 3.1.1 308 | 309 | ```php 310 | listen( 332 | 'workflow.straight.guard', 333 | 'App\Listeners\BlogPostWorkflowSubscriber@onGuard' 334 | ); 335 | 336 | // workflow.leave 337 | // workflow.[workflow name].leave 338 | // workflow.[workflow name].leave.[place name] 339 | $events->listen( 340 | 'workflow.straight.leave', 341 | 'App\Listeners\BlogPostWorkflowSubscriber@onLeave' 342 | ); 343 | 344 | // workflow.transition 345 | // workflow.[workflow name].transition 346 | // workflow.[workflow name].transition.[transition name] 347 | $events->listen( 348 | 'workflow.straight.transition', 349 | 'App\Listeners\BlogPostWorkflowSubscriber@onTransition' 350 | ); 351 | 352 | // workflow.enter 353 | // workflow.[workflow name].enter 354 | // workflow.[workflow name].enter.[place name] 355 | $events->listen( 356 | 'workflow.straight.enter', 357 | 'App\Listeners\BlogPostWorkflowSubscriber@onEnter' 358 | ); 359 | 360 | // workflow.entered 361 | // workflow.[workflow name].entered 362 | // workflow.[workflow name].entered.[place name] 363 | $events->listen( 364 | 'workflow.straight.entered', 365 | 'App\Listeners\BlogPostWorkflowSubscriber@onEntered' 366 | ); 367 | 368 | // workflow.completed 369 | // workflow.[workflow name].completed 370 | // workflow.[workflow name].completed.[transition name] 371 | $events->listen( 372 | 'workflow.straight.completed', 373 | 'App\Listeners\BlogPostWorkflowSubscriber@onCompleted' 374 | ); 375 | 376 | // workflow.announce 377 | // workflow.[workflow name].announce 378 | // workflow.[workflow name].announce.[transition name] 379 | $events->listen( 380 | 'workflow.straight.announce', 381 | 'App\Listeners\BlogPostWorkflowSubscriber@onAnnounce' 382 | ); 383 | } 384 | } 385 | ``` 386 | 387 | You can subscribe to events in a more typical Laravel-style, although this is no longer recommended as it can result in "duplicate" events depending on how you listen to events. 388 | 389 | ```php 390 | getOriginalEvent(); 405 | 406 | /** @var App\BlogPost $post */ 407 | $post = $originalEvent->getSubject(); 408 | $title = $post->title; 409 | 410 | if (empty($title)) { 411 | // Posts with no title should not be allowed 412 | $originalEvent->setBlocked(true); 413 | } 414 | } 415 | 416 | /** 417 | * Handle workflow leave event. 418 | */ 419 | public function onLeave($event) 420 | { 421 | // The event can also proxy to the original event 422 | $subject = $event->getSubject(); 423 | // is the same as: 424 | $subject = $event->getOriginalEvent()->getSubject(); 425 | } 426 | 427 | /** 428 | * Handle workflow transition event. 429 | */ 430 | public function onTransition($event) {} 431 | 432 | /** 433 | * Handle workflow enter event. 434 | */ 435 | public function onEnter($event) {} 436 | 437 | /** 438 | * Handle workflow entered event. 439 | */ 440 | public function onEntered($event) {} 441 | 442 | /** 443 | * Register the listeners for the subscriber. 444 | * 445 | * @param Illuminate\Events\Dispatcher $events 446 | */ 447 | public function subscribe($events) 448 | { 449 | $events->listen( 450 | 'ZeroDaHero\LaravelWorkflow\Events\GuardEvent', 451 | 'App\Listeners\BlogPostWorkflowSubscriber@onGuard' 452 | ); 453 | 454 | $events->listen( 455 | 'ZeroDaHero\LaravelWorkflow\Events\LeaveEvent', 456 | 'App\Listeners\BlogPostWorkflowSubscriber@onLeave' 457 | ); 458 | 459 | $events->listen( 460 | 'ZeroDaHero\LaravelWorkflow\Events\TransitionEvent', 461 | 'App\Listeners\BlogPostWorkflowSubscriber@onTransition' 462 | ); 463 | 464 | $events->listen( 465 | 'ZeroDaHero\LaravelWorkflow\Events\EnterEvent', 466 | 'App\Listeners\BlogPostWorkflowSubscriber@onEnter' 467 | ); 468 | 469 | $events->listen( 470 | 'ZeroDaHero\LaravelWorkflow\Events\EnteredEvent', 471 | 'App\Listeners\BlogPostWorkflowSubscriber@onEntered' 472 | ); 473 | } 474 | 475 | } 476 | ``` 477 | 478 | ## Workflow vs State Machine 479 | 480 | When using a multi-state workflow, it becomes necessary to distinguish between an array of multiple places that can transition to one place, or a situation where a subject in exactly multiple places transitions to one. Since the config is a PHP array, you must "nest" the latter situation into an array, so that it builds a transition using an array of places, rather that looping through single places. 481 | 482 | ### Example 1. Exactly two places transition to one 483 | 484 | In this example, a draft must be in both `content_approved` and `legal_approved` at the same time 485 | 486 | ```php 487 | [ 491 | 'type' => 'workflow', 492 | 'metadata' => [ 493 | 'title' => 'Blog Publishing Workflow', 494 | ], 495 | 'marking_store' => [ 496 | 'property' => 'currentPlace' 497 | ], 498 | 'supports' => ['App\BlogPost'], 499 | 'places' => [ 500 | 'draft', 501 | 'content_review', 502 | 'content_approved', 503 | 'legal_review', 504 | 'legal_approved', 505 | 'published' 506 | ], 507 | 'transitions' => [ 508 | 'to_review' => [ 509 | 'from' => 'draft', 510 | 'to' => ['content_review', 'legal_review'], 511 | ], 512 | // ... transitions to "approved" states here 513 | 'publish' => [ 514 | 'from' => [ // note array in array 515 | ['content_review', 'legal_review'] 516 | ], 517 | 'to' => 'published' 518 | ], 519 | // ... 520 | ], 521 | ] 522 | ]; 523 | ``` 524 | 525 | ### Example 2. Either of two places transition to one 526 | 527 | In this example, a draft can transition from EITHER `content_approved` OR `legal_approved` to `published` 528 | 529 | ```php 530 | [ 534 | 'type' => 'workflow', 535 | 'metadata' => [ 536 | 'title' => 'Blog Publishing Workflow', 537 | ], 538 | 'marking_store' => [ 539 | 'property' => 'currentPlace' 540 | ], 541 | 'supports' => ['App\BlogPost'], 542 | 'places' => [ 543 | 'draft', 544 | 'content_review', 545 | 'content_approved', 546 | 'legal_review', 547 | 'legal_approved', 548 | 'published' 549 | ], 550 | 'transitions' => [ 551 | 'to_review' => [ 552 | 'from' => 'draft', 553 | 'to' => ['content_review', 'legal_review'], 554 | ], 555 | // ... transitions to "approved" states here 556 | 'publish' => [ 557 | 'from' => [ 558 | 'content_review', 559 | 'legal_review' 560 | ], 561 | 'to' => 'published' 562 | ], 563 | // ... 564 | ], 565 | ] 566 | ]; 567 | ``` 568 | 569 | ## Dump Workflows 570 | Symfony workflow uses GraphvizDumper to create the workflow image. You may need to install the `dot` command of [Graphviz](http://www.graphviz.org/) 571 | 572 | php artisan workflow:dump workflow_name --class App\\BlogPost 573 | 574 | You can change the image format with the `--format` option. By default the format is png. 575 | 576 | php artisan workflow:dump workflow_name --format=jpg 577 | 578 | Similar to [Symfony](https://symfony.com/doc/current/workflow/dumping-workflows.html#styling). You can use `--with-metadata` to include workflow's metadata 579 | 580 | php artisan workflow:dump workflow_name --with-metadata 581 | 582 | If you would like to output to a different directory than root, you can use the `--disk` and `--path` options to set the Storage disk (`local` by default) and path (`root_path()` by default). 583 | 584 | php artisan workflow:dump workflow-name --class=App\\BlogPost --disk=s3 --path="workflows/diagrams/" 585 | 586 | ## Use in tracking mode 587 | 588 | If you are loading workflow definitions through some dynamic means (perhaps via DB), you'll most likely want to turn on registry tracking. This will enable you to see what has been loaded, to prevent or ignore duplicate workflow definitions. 589 | 590 | Set `track_loaded` to `true` in the `workflow_registry.php` config file. 591 | 592 | ```php 593 | false, 603 | 604 | /** 605 | * Only used when track_loaded = true 606 | * 607 | * When set to true, a registering a duplicate workflow will be ignored (will not load the new definition) 608 | * When set to false, a duplicate workflow will throw a DuplicateWorkflowException 609 | */ 610 | 'ignore_duplicates' => false, 611 | 612 | ]; 613 | ``` 614 | 615 | You can dynamically load a workflow by using the `addFromArray` method on the workflow registry 616 | 617 | ```php 618 | make('workflow'); 626 | $workflowName = 'straight'; 627 | $workflowDefinition = [ 628 | // Workflow definition here 629 | // (same format as config/symfony docs) 630 | // This should be the definition only, 631 | // not including the key for the name. 632 | // See note below on initial_places for an example. 633 | ]; 634 | 635 | $registry->addFromArray($workflowName, $workflowDefinition); 636 | 637 | // or if catching duplicates 638 | 639 | try { 640 | $registry->addFromArray($workflowName, $workflowDefinition); 641 | } catch (DuplicateWorkflowException $e) { 642 | // already loaded 643 | } 644 | } 645 | ``` 646 | 647 | NOTE: There's no persistence for dynamic workflows, this package assumes you're storing those somehow (DB, etc). To use the dynamic workflows, you will need to load the workflow prior to using it. The `loadWorkflow()` method above could be tied into a model `boot()` or similar. 648 | 649 | You may also specify an `initial_places` in your workflow definition, if it is not the first place in the "places" list. 650 | 651 | ```php 652 | 'workflow', // or 'state_machine' 656 | 'metadata' => [ 657 | 'title' => 'Blog Publishing Workflow', 658 | ], 659 | 'marking_store' => [ 660 | 'property' => 'currentPlace' 661 | ], 662 | 'supports' => ['App\BlogPost'], 663 | 'places' => [ 664 | 'review', 665 | 'rejected', 666 | 'published', 667 | 'draft', => [ 668 | 'metadata' => [ 669 | 'max_num_of_words' => 500, 670 | ] 671 | ] 672 | ], 673 | 'initial_places' => 'draft', // or set to an array if multiple initial places 674 | 'transitions' => [ 675 | 'to_review' => [ 676 | 'from' => 'draft', 677 | 'to' => 'review', 678 | 'metadata' => [ 679 | 'priority' => 0.5, 680 | ] 681 | ], 682 | 'publish' => [ 683 | 'from' => 'review', 684 | 'to' => 'published' 685 | ], 686 | 'reject' => [ 687 | 'from' => 'review', 688 | 'to' => 'rejected' 689 | ] 690 | ], 691 | ]; 692 | ``` 693 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zerodahero/laravel-workflow", 3 | "description": "Integerate Symfony Workflow component into Laravel.", 4 | "keywords": [ 5 | "workflow", 6 | "symfony", 7 | "laravel", 8 | "laravel7", 9 | "laravel8" 10 | ], 11 | "license": "MIT", 12 | "require": { 13 | "php": "^8.1|^8.2|^8.3", 14 | "symfony/workflow": "^6.0|^7.0", 15 | "symfony/process": "^6.0|^7.0", 16 | "symfony/event-dispatcher-contracts": "^2.4|^3.0", 17 | "illuminate/console": "^8.0|^9.0|^10.0|^11.0|^12.0", 18 | "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0", 19 | "illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0" 20 | }, 21 | "autoload": { 22 | "psr-4": { 23 | "ZeroDaHero\\LaravelWorkflow\\": "src/" 24 | } 25 | }, 26 | "autoload-dev": { 27 | "psr-4": { 28 | "Tests\\": "tests/" 29 | } 30 | }, 31 | "extra": { 32 | "branch-alias": { 33 | "dev-master": "3.x-dev" 34 | }, 35 | "laravel": { 36 | "providers": [ 37 | "ZeroDaHero\\LaravelWorkflow\\WorkflowServiceProvider" 38 | ], 39 | "aliases": { 40 | "Workflow": "ZeroDaHero\\LaravelWorkflow\\Facades\\WorkflowFacade" 41 | } 42 | } 43 | }, 44 | "require-dev": { 45 | "mockery/mockery": "^1.2", 46 | "phpunit/phpunit": "^9.0|^10.5|^11.5.3", 47 | "symfony/var-dumper": "^6.0|^7.0", 48 | "fakerphp/faker": "^1.13|^1.9.1", 49 | "orchestra/testbench": "^6.0|^7.0|^8.0|^9.0|^10.0", 50 | "symfony/contracts": "^2.3|^3.5" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /config/workflow.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'type' => 'state_machine', 6 | 'supports' => ['stdClass'], 7 | 'places' => ['a', 'b', 'c'], 8 | 'transitions' => [ 9 | 't1' => [ 10 | 'from' => 'a', 11 | 'to' => 'b', 12 | ], 13 | 't2' => [ 14 | 'from' => 'b', 15 | 'to' => 'c', 16 | ], 17 | ], 18 | ], 19 | ]; 20 | -------------------------------------------------------------------------------- /config/workflow_registry.php: -------------------------------------------------------------------------------- 1 | false, 10 | 11 | /** 12 | * Only used when track_loaded = true 13 | * 14 | * When set to true, a registering a duplicate workflow will be ignored (will not load the new definition) 15 | * When set to false, a duplicate workflow will throw a DuplicateWorkflowException 16 | */ 17 | 'ignore_duplicates' => false, 18 | ]; 19 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | ./tests 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/Commands/WorkflowDumpCommand.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | class WorkflowDumpCommand extends Command 19 | { 20 | /** 21 | * The name and signature of the console command. 22 | * 23 | * @var string 24 | */ 25 | protected $signature = 'workflow:dump 26 | {workflow : name of workflow from configuration} 27 | {--class= : the support class name} 28 | {--format=png : the image format} 29 | {--disk=local : the storage disk name} 30 | {--path= : the optional path within selected disk} 31 | {--with-metadata : dumps metadata beneath the label }'; 32 | 33 | /** 34 | * The console command description. 35 | * 36 | * @var string 37 | */ 38 | protected $description = 'GraphvizDumper dumps a workflow as a graphviz file. 39 | You can convert the generated dot file with the dot utility (http://www.graphviz.org/):'; 40 | 41 | /** 42 | * Execute the console command. 43 | * 44 | * @return mixed 45 | */ 46 | public function handle() 47 | { 48 | $workflowName = $this->argument('workflow'); 49 | $format = $this->option('format'); 50 | $class = $this->option('class'); 51 | $config = Config::get('workflow'); 52 | $disk = $this->option('disk'); 53 | $optionalPath = $this->option('path'); 54 | $withMetadata = $this->option('with-metadata'); 55 | 56 | if ($disk === 'local') { 57 | $optionalPath ??= '.'; 58 | } 59 | $path = Storage::disk($disk)->path($optionalPath); 60 | 61 | if ($optionalPath && ! Storage::disk($disk)->exists($optionalPath)) { 62 | Storage::disk($disk)->makeDirectory($optionalPath); 63 | } 64 | 65 | if (! isset($config[$workflowName])) { 66 | throw new Exception("Workflow {$workflowName} is not configured."); 67 | } 68 | 69 | if (false === array_search($class, $config[$workflowName]['supports'])) { 70 | throw new Exception("Workflow {$workflowName} has no support for class {$class}." . 71 | ' Please specify a valid support class with the --class option.'); 72 | } 73 | 74 | $dumperOptions = [ 75 | 'with-metadata' => $withMetadata, 76 | ]; 77 | 78 | $subject = new $class(); 79 | $workflow = Workflow::get($subject, $workflowName); 80 | $definition = $workflow->getDefinition(); 81 | 82 | $dumper = new GraphvizDumper(); 83 | 84 | if ($workflow instanceof StateMachine) { 85 | $dumper = new StateMachineGraphvizDumper(); 86 | } 87 | 88 | $dotCommand = ['dot', "-T{$format}", '-o', "{$workflowName}.{$format}"]; 89 | 90 | $process = new Process($dotCommand); 91 | $process->setWorkingDirectory($path); 92 | $process->setInput($dumper->dump($definition, options: $dumperOptions)); 93 | $process->mustRun(); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Events/AnnounceEvent.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | class AnnounceEvent extends BaseEvent 9 | { 10 | } 11 | -------------------------------------------------------------------------------- /src/Events/BaseEvent.php: -------------------------------------------------------------------------------- 1 | get_class($this), 22 | 'subject' => $this->getSubject(), 23 | 'marking' => $this->getMarking(), 24 | 'transition' => $this->getTransition(), 25 | 'workflow' => [ 26 | 'name' => $this->getWorkflowName(), 27 | ], 28 | ]; 29 | } 30 | 31 | public function __unserialize(array $data): void 32 | { 33 | $workflowName = $data['workflow']['name'] ?? null; 34 | parent::__construct( 35 | $data['subject'], 36 | $data['marking'], 37 | $data['transition'], 38 | Workflow::get($data['subject'], $workflowName) 39 | ); 40 | } 41 | 42 | /** 43 | * Creates a new instance from the base Symfony event 44 | */ 45 | public static function newFromBase(Event $symfonyEvent) 46 | { 47 | return new static( 48 | $symfonyEvent->getSubject(), 49 | $symfonyEvent->getMarking(), 50 | $symfonyEvent->getTransition(), 51 | $symfonyEvent->getWorkflow() 52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Events/CompletedEvent.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | class CompletedEvent extends BaseEvent 9 | { 10 | } 11 | -------------------------------------------------------------------------------- /src/Events/DispatcherAdapter.php: -------------------------------------------------------------------------------- 1 | GuardEvent::class, 12 | 'leave' => LeaveEvent::class, 13 | 'transition' => TransitionEvent::class, 14 | 'enter' => EnterEvent::class, 15 | 'entered' => EnteredEvent::class, 16 | 'completed' => CompletedEvent::class, 17 | 'announce' => AnnounceEvent::class, 18 | ]; 19 | 20 | protected $dispatcher; 21 | 22 | private $plainEvents; 23 | 24 | public function __construct(Dispatcher $dispatcher) 25 | { 26 | $this->dispatcher = $dispatcher; 27 | $this->plainEvents = array_map(function ($event) { 28 | return "workflow.{$event}"; 29 | }, array_keys(static::EVENT_MAP)); 30 | } 31 | 32 | /** 33 | * Dispatches an event to all registered listeners. 34 | * 35 | * @param object $event The event to pass to the event handlers/listeners 36 | * @param string|null $eventName The name of the event to dispatch. If not supplied, 37 | * the class of $event should be used instead. 38 | * 39 | * @return object The passed $event MUST be returned 40 | */ 41 | public function dispatch(object $event, ?string $eventName = null): object 42 | { 43 | $name = is_null($eventName) ? get_class($event) : $eventName; 44 | 45 | $eventToDispatch = $this->translateEvent($eventName, $event); 46 | 47 | // Only dispatch the class event once 48 | if ($this->shouldDispatchPlainClassEvent($eventName)) { 49 | $this->dispatcher->dispatch($eventToDispatch); 50 | } 51 | 52 | // Dispatch with the Symfony dot syntax event names 53 | $this->dispatcher->dispatch($name, $eventToDispatch); 54 | 55 | return $eventToDispatch; 56 | } 57 | 58 | private function shouldDispatchPlainClassEvent(?string $eventName = null) 59 | { 60 | if (! $eventName) { 61 | return false; 62 | } 63 | 64 | return in_array($eventName, $this->plainEvents); 65 | } 66 | 67 | private function translateEvent(?string $eventName, object $symfonyEvent): object 68 | { 69 | if (is_null($eventName)) { 70 | return WorkflowEvent::newFromBase($symfonyEvent); 71 | } 72 | 73 | $event = $this->parseWorkflowEventFromEventName($eventName); 74 | 75 | if (! $event) { 76 | return WorkflowEvent::newFromBase($symfonyEvent); 77 | } 78 | 79 | $translatedEventClass = static::EVENT_MAP[$event]; 80 | 81 | return $translatedEventClass::newFromBase($symfonyEvent); 82 | } 83 | 84 | private function parseWorkflowEventFromEventName(string $eventName) 85 | { 86 | $eventSearch = preg_match('/\.(?P' . implode('|', array_keys(static::EVENT_MAP)) . ')(\.|$)/i', $eventName, $eventMatches); 87 | 88 | if (! $eventSearch) { 89 | // no results or error 90 | return false; 91 | } 92 | $event = $eventMatches['event'] ?? false; 93 | 94 | if (! array_key_exists($event, static::EVENT_MAP)) { 95 | // fallback for no mapped event known 96 | return false; 97 | } 98 | 99 | return $event; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Events/EnterEvent.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | class EnterEvent extends BaseEvent 9 | { 10 | } 11 | -------------------------------------------------------------------------------- /src/Events/EnteredEvent.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | class EnteredEvent extends BaseEvent 9 | { 10 | } 11 | -------------------------------------------------------------------------------- /src/Events/GuardEvent.php: -------------------------------------------------------------------------------- 1 | symfonyProxyEvent = new SymfonyGuardEvent($subject, $marking, $transition, $workflow); 29 | } 30 | 31 | public function __call($name, $arguments) 32 | { 33 | if (method_exists($this->symfonyProxyEvent, $name)) { 34 | return call_user_func_array([$this->symfonyProxyEvent,$name], $arguments); 35 | } 36 | } 37 | 38 | public function __unserialize(array $data): void 39 | { 40 | parent::__unserialize($data); 41 | 42 | $this->symfonyProxyEvent = new SymfonyGuardEvent( 43 | $this->getSubject(), 44 | $this->getMarking(), 45 | $this->getTransition(), 46 | $this->getWorkflow() 47 | ); 48 | } 49 | 50 | /** 51 | * Creates a new instance from the base Symfony event 52 | */ 53 | public static function newFromBase(Event $symfonyEvent) 54 | { 55 | $instance = new static( 56 | $symfonyEvent->getSubject(), 57 | $symfonyEvent->getMarking(), 58 | $symfonyEvent->getTransition(), 59 | $symfonyEvent->getWorkflow() 60 | ); 61 | 62 | $instance->symfonyProxyEvent = $symfonyEvent; 63 | 64 | return $instance; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Events/LeaveEvent.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | class LeaveEvent extends BaseEvent 9 | { 10 | } 11 | -------------------------------------------------------------------------------- /src/Events/TransitionEvent.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | class TransitionEvent extends BaseEvent 9 | { 10 | } 11 | -------------------------------------------------------------------------------- /src/Events/WorkflowEvent.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class WorkflowFacade extends Facade 11 | { 12 | protected static function getFacadeAccessor() 13 | { 14 | return 'workflow'; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/MarkingStores/EloquentMarkingStore.php: -------------------------------------------------------------------------------- 1 | singleState = $singleState; 27 | $this->property = $property; 28 | } 29 | 30 | /** 31 | * {@inheritdoc} 32 | */ 33 | public function getMarking(object $subject): Marking 34 | { 35 | $marking = $subject->{$this->property}; 36 | 37 | if (null === $marking) { 38 | return new Marking(); 39 | } 40 | 41 | if ($this->singleState) { 42 | $marking = [(string) $marking => 1]; 43 | } elseif (! \is_array($marking)) { 44 | throw new LogicException(sprintf('The marking stored in "%s::$%s" is not an array and the Workflow\'s Marking store is instantiated with $singleState=false.', get_debug_type($subject), $this->property)); 45 | } 46 | 47 | return new Marking($marking); 48 | } 49 | 50 | /** 51 | * {@inheritdoc} 52 | */ 53 | public function setMarking(object $subject, Marking $marking, array $context = []): void 54 | { 55 | $marking = $marking->getPlaces(); 56 | 57 | if ($this->singleState) { 58 | $marking = key($marking); 59 | } 60 | 61 | // We'll check for the mutator first, and use that with the context. 62 | $method = 'set' . ucfirst($this->property) . 'Attribute'; 63 | 64 | if (method_exists($subject, $method)) { 65 | $subject->{$method}($marking, $context); 66 | 67 | return; 68 | } 69 | 70 | // If the mutator doesn't exist, defer to Eloquent for setting/casting/etc 71 | $subject->{$this->property} = $marking; 72 | } 73 | 74 | /** 75 | * Return the property name of the marking 76 | * 77 | * @return mixed 78 | */ 79 | public function getProperty() 80 | { 81 | return $this->property; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Traits/WorkflowTrait.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | trait WorkflowTrait 11 | { 12 | public function workflow_apply($transition, $workflow = null, array $context = []) 13 | { 14 | if (is_array($workflow)) { 15 | $context = $workflow; 16 | $workflow = null; 17 | } 18 | 19 | return Workflow::get($this, $workflow)->apply($this, $transition, $context); 20 | } 21 | 22 | public function workflow_can($transition, $workflow = null) 23 | { 24 | return Workflow::get($this, $workflow)->can($this, $transition); 25 | } 26 | 27 | public function workflow_transitions($workflow = null) 28 | { 29 | return Workflow::get($this, $workflow)->getEnabledTransitions($this); 30 | } 31 | 32 | public function workflow_get($workflow = null) 33 | { 34 | return Workflow::get($this, $workflow); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/WorkflowRegistry.php: -------------------------------------------------------------------------------- 1 | registry = new Registry(); 63 | $this->config = $config; 64 | $this->registryConfig = $registryConfig ?? $this->getDefaultRegistryConfig(); 65 | $this->dispatcher = new DispatcherAdapter($laravelDispatcher); 66 | 67 | foreach ($this->config as $name => $workflowData) { 68 | $this->addFromArray($name, $workflowData); 69 | } 70 | } 71 | 72 | /** 73 | * Return the $subject workflow 74 | * 75 | * @param object $subject 76 | * @param string $workflowName 77 | * 78 | * @return Workflow 79 | */ 80 | public function get($subject, $workflowName = null) 81 | { 82 | return $this->registry->get($subject, $workflowName); 83 | } 84 | 85 | /** 86 | * Returns all workflows for the given subject 87 | * 88 | * @param object $subject 89 | * 90 | * @return Workflow[] 91 | */ 92 | public function all($subject): array 93 | { 94 | return $this->registry->all($subject); 95 | } 96 | 97 | /** 98 | * Add a workflow to the subject 99 | * 100 | * @param Workflow $workflow 101 | * @param string $supportStrategy 102 | * 103 | * @return void 104 | */ 105 | public function add(Workflow $workflow, $supportStrategy) 106 | { 107 | if (! $this->isLoaded($workflow->getName(), $supportStrategy)) { 108 | $this->registry->addWorkflow($workflow, new InstanceOfSupportStrategy($supportStrategy)); 109 | $this->setLoaded($workflow->getName(), $supportStrategy); 110 | } 111 | } 112 | 113 | /** 114 | * Gets the loaded workflows 115 | * 116 | * @param string $supportStrategy 117 | * 118 | * @throws RegistryNotTrackedException 119 | * 120 | * @return array 121 | */ 122 | public function getLoaded($supportStrategy = null) 123 | { 124 | if (! $this->registryConfig['track_loaded']) { 125 | throw new RegistryNotTrackedException('This registry is not being tracked, and thus has not recorded any loaded workflows.'); 126 | } 127 | 128 | if ($supportStrategy) { 129 | return $this->loadedWorkflows[$supportStrategy] ?? []; 130 | } 131 | 132 | return $this->loadedWorkflows; 133 | } 134 | 135 | /** 136 | * Add a workflow to the registry from array 137 | * 138 | * @param string $name 139 | * @param array $workflowData 140 | * 141 | * @throws \ReflectionException 142 | * 143 | * @return void 144 | */ 145 | public function addFromArray($name, array $workflowData) 146 | { 147 | $metadata = $this->extractWorkflowPlacesMetaData($workflowData); 148 | 149 | $builder = new DefinitionBuilder($workflowData['places']); 150 | 151 | foreach ($workflowData['transitions'] as $transitionName => $transition) { 152 | if (! is_string($transitionName)) { 153 | $transitionName = $transition['name']; 154 | } 155 | 156 | foreach ((array) $transition['from'] as $from) { 157 | $transitionObj = new Transition($transitionName, $from, $transition['to']); 158 | $builder->addTransition($transitionObj); 159 | 160 | if (isset($transition['metadata'])) { 161 | $metadata['transitions']->attach($transitionObj, $transition['metadata']); 162 | } 163 | } 164 | } 165 | 166 | $metadataStore = new InMemoryMetadataStore( 167 | $metadata['workflow'], 168 | $metadata['places'], 169 | $metadata['transitions'] 170 | ); 171 | 172 | $builder->setMetadataStore($metadataStore); 173 | 174 | if (isset($workflowData['initial_places'])) { 175 | $builder->setInitialPlaces($workflowData['initial_places']); 176 | } 177 | 178 | $eventsToDispatch = $this->parseEventsToDispatch($workflowData); 179 | 180 | $definition = $builder->build(); 181 | $markingStore = $this->getMarkingStoreInstance($workflowData); 182 | $workflow = $this->getWorkflowInstance($name, $workflowData, $definition, $markingStore, $eventsToDispatch); 183 | 184 | foreach ($workflowData['supports'] as $supportedClass) { 185 | $this->add($workflow, $supportedClass); 186 | } 187 | } 188 | 189 | /** 190 | * Checks if the workflow is already loaded for this supported class 191 | * 192 | * @param string $workflowName 193 | * @param string $supportStrategy 194 | * 195 | * @throws DuplicateWorkflowException 196 | * 197 | * @return bool 198 | */ 199 | public function isLoaded($workflowName, $supportStrategy) 200 | { 201 | if (! $this->registryConfig['track_loaded']) { 202 | return false; 203 | } 204 | 205 | if (isset($this->loadedWorkflows[$supportStrategy]) && in_array($workflowName, $this->loadedWorkflows[$supportStrategy])) { 206 | if (! $this->registryConfig['ignore_duplicates']) { 207 | throw new DuplicateWorkflowException(sprintf('Duplicate workflow (%s) attempting to be loaded for %s', $workflowName, $supportStrategy)); // phpcs:ignore 208 | } 209 | 210 | return true; 211 | } 212 | 213 | return false; 214 | } 215 | 216 | /** 217 | * Parses events to dispatch data from config 218 | */ 219 | protected function parseEventsToDispatch(array $workflowData) 220 | { 221 | if (array_key_exists('events_to_dispatch', $workflowData)) { 222 | return $workflowData['events_to_dispatch']; 223 | } 224 | 225 | // Null dispatches all, [] dispatches none. 226 | return null; 227 | } 228 | 229 | /** 230 | * Gets the default registry config 231 | * 232 | * @return array 233 | */ 234 | protected function getDefaultRegistryConfig() 235 | { 236 | return [ 237 | 'track_loaded' => false, 238 | 'ignore_duplicates' => true, 239 | ]; 240 | } 241 | 242 | /** 243 | * Sets the workflow as loaded 244 | * 245 | * @param string $workflowName 246 | * @param string $supportStrategy 247 | * 248 | * @return void 249 | */ 250 | protected function setLoaded($workflowName, $supportStrategy) 251 | { 252 | if (! $this->registryConfig['track_loaded']) { 253 | return; 254 | } 255 | 256 | if (! isset($this->loadedWorkflows[$supportStrategy])) { 257 | $this->loadedWorkflows[$supportStrategy] = []; 258 | } 259 | 260 | $this->loadedWorkflows[$supportStrategy][] = $workflowName; 261 | } 262 | 263 | /** 264 | * Return the workflow instance 265 | * 266 | * @param string $name 267 | * @param array $workflowData 268 | * @param Definition $definition 269 | * @param MarkingStoreInterface $markingStore 270 | * 271 | * @return Workflow 272 | */ 273 | protected function getWorkflowInstance( 274 | $name, 275 | array $workflowData, 276 | Definition $definition, 277 | MarkingStoreInterface $markingStore, 278 | ?array $eventsToDispatch = null 279 | ) { 280 | if (isset($workflowData['class'])) { 281 | $className = $workflowData['class']; 282 | 283 | return new $className($definition, $markingStore, $this->dispatcher, $name); 284 | } elseif (isset($workflowData['type']) && $workflowData['type'] === 'state_machine') { 285 | return new StateMachine($definition, $markingStore, $this->dispatcher, $name); 286 | } else { 287 | return new Workflow($definition, $markingStore, $this->dispatcher, $name, $eventsToDispatch); 288 | } 289 | } 290 | 291 | /** 292 | * Return the making store instance 293 | * 294 | * @param array $workflowData 295 | * 296 | * @throws \ReflectionException 297 | * 298 | * @return MarkingStoreInterface 299 | */ 300 | protected function getMarkingStoreInstance(array $workflowData) 301 | { 302 | $markingStoreData = $workflowData['marking_store'] ?? []; 303 | $property = $markingStoreData['property'] ?? 'marking'; 304 | 305 | if (array_key_exists('type', $markingStoreData)) { 306 | $type = $markingStoreData['type']; 307 | } else { 308 | $workflowType = $workflowData['type'] ?? 'workflow'; 309 | $type = ($workflowType === 'state_machine') ? 'single_state' : 'multiple_state'; 310 | } 311 | 312 | $markingStoreClass = $markingStoreData['class'] ?? EloquentMarkingStore::class; 313 | 314 | return new $markingStoreClass( 315 | ($type === 'single_state'), 316 | $property 317 | ); 318 | } 319 | 320 | /** 321 | * Extracts workflow and places metadata from the config 322 | * NOTE: This modifies the provided config! 323 | * 324 | * @param array $workflowData 325 | * 326 | * @return array 327 | */ 328 | protected function extractWorkflowPlacesMetaData(array &$workflowData) 329 | { 330 | $metadata = [ 331 | 'workflow' => [], 332 | 'places' => [], 333 | 'transitions' => new \SplObjectStorage(), 334 | ]; 335 | 336 | if (isset($workflowData['metadata'])) { 337 | $metadata['workflow'] = $workflowData['metadata']; 338 | unset($workflowData['metadata']); 339 | } 340 | 341 | foreach ($workflowData['places'] as $key => &$place) { 342 | if (is_int($key) && ! is_array($place)) { 343 | // no metadata, just place name 344 | continue; 345 | } 346 | 347 | if (isset($place['metadata'])) { 348 | if (is_int($key) && ! $place['name']) { 349 | throw new InvalidArgumentException(sprintf('Unknown name for place at index %d', $key)); 350 | } 351 | 352 | $name = ! is_int($key) ? $key : $place['name']; 353 | $metadata['places'][$name] = $place['metadata']; 354 | 355 | $place = $name; 356 | } 357 | } 358 | 359 | return $metadata; 360 | } 361 | } 362 | -------------------------------------------------------------------------------- /src/WorkflowServiceProvider.php: -------------------------------------------------------------------------------- 1 | configPath(); 22 | 23 | $this->publishes([ 24 | "{$configPath}/workflow.php" => $this->publishPath('workflow.php'), 25 | "{$configPath}/workflow_registry.php" => $this->publishPath('workflow_registry.php'), 26 | ], 'config'); 27 | } 28 | 29 | /** 30 | * Register the application services. 31 | * 32 | * @return void 33 | */ 34 | public function register() 35 | { 36 | $this->mergeConfigFrom( 37 | $this->configPath() . '/workflow_registry.php', 38 | 'workflow_registry' 39 | ); 40 | 41 | $this->commands($this->commands); 42 | 43 | $this->app->singleton('workflow', function ($app) { 44 | $workflowConfigs = $app->make('config')->get('workflow', []); 45 | $registryConfig = $app->make('config')->get('workflow_registry'); 46 | 47 | return new WorkflowRegistry($workflowConfigs, $registryConfig, $app->make(Dispatcher::class)); 48 | }); 49 | } 50 | 51 | /** 52 | * Get the services provided by the provider. 53 | * 54 | * @return array 55 | */ 56 | public function provides() 57 | { 58 | return ['workflow']; 59 | } 60 | 61 | protected function configPath() 62 | { 63 | return __DIR__ . '/../config'; 64 | } 65 | 66 | protected function publishPath($configFile) 67 | { 68 | return (function_exists('config_path')) 69 | ? config_path($configFile) 70 | : base_path('config/' . $configFile); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/BaseWorkflowTestCase.php: -------------------------------------------------------------------------------- 1 | WorkflowFacade::class, 20 | ]; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/DispatchAdapterTest.php: -------------------------------------------------------------------------------- 1 | shouldReceive('dispatch') 58 | ->once() 59 | ->with($expectedPackageEvent); 60 | } 61 | $mockDispatcher->shouldReceive('dispatch') 62 | ->once() 63 | ->with($expectedDotName, $expectedPackageEvent); 64 | $adapter = new DispatcherAdapter($mockDispatcher); 65 | 66 | $event = $adapter->dispatch($symfonyEvent, $eventDotName); 67 | $this->assertInstanceOf($expectedPackageEvent, $event); 68 | 69 | $this->assertInstanceOf(BaseEvent::class, $event); 70 | } 71 | 72 | public static function providesEventScenarios() 73 | { 74 | $faker = \Faker\Factory::create(); 75 | 76 | $dispatcher = new DispatcherAdapter(Mockery::mock(Dispatcher::class)); 77 | $eventList = static::getProtectedConstant($dispatcher, 'EVENT_MAP'); 78 | $mockWorkflow = Mockery::mock(WorkflowInterface::class); 79 | 80 | $reverseMap = [ 81 | GuardEvent::class => \Symfony\Component\Workflow\Event\GuardEvent::class, 82 | LeaveEvent::class => \Symfony\Component\Workflow\Event\LeaveEvent::class, 83 | TransitionEvent::class => \Symfony\Component\Workflow\Event\TransitionEvent::class, 84 | EnterEvent::class => \Symfony\Component\Workflow\Event\EnterEvent::class, 85 | EnteredEvent::class => \Symfony\Component\Workflow\Event\EnteredEvent::class, 86 | CompletedEvent::class => \Symfony\Component\Workflow\Event\CompletedEvent::class, 87 | AnnounceEvent::class => \Symfony\Component\Workflow\Event\AnnounceEvent::class, 88 | ]; 89 | 90 | foreach ([ 91 | 'no dots' => ['', ''], 92 | 'transition dot' => ['', '.'], 93 | 'name dot' => ['.', ''], 94 | 'both dot' => ['.','.'], 95 | ] as $dotScenario => [$nameSeparator, $transitionSeparator]) { 96 | foreach ($eventList as $eventType => $expectedEventClass) { 97 | // Cover scenarios with '.' in the workflow name or transition name 98 | $transition = implode($transitionSeparator, $faker->words(3)); 99 | $name = implode($nameSeparator, $faker->words(3)); 100 | 101 | $symfonyEvent = $reverseMap[$expectedEventClass]; 102 | $symfonyEvent = new $symfonyEvent(new stdClass(), new Marking(), new Transition($transition, [], []), $mockWorkflow); 103 | 104 | foreach ([ 105 | "workflow.{$eventType}", 106 | "workflow.{$name}.{$eventType}", 107 | "workflow.{$name}.{$eventType}.{$transition}", 108 | ] as $eventName) { 109 | yield "{$eventName} ({$dotScenario})" => [ 110 | $expectedEventClass, 111 | $symfonyEvent, 112 | $eventName, 113 | $eventName, 114 | ]; 115 | } 116 | 117 | yield "No event name {$eventType} ({$dotScenario})" => [ 118 | WorkflowEvent::class, 119 | $symfonyEvent, 120 | null, 121 | get_class($symfonyEvent), 122 | ]; 123 | } 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /tests/EventTest.php: -------------------------------------------------------------------------------- 1 | 1]), 32 | new \Symfony\Component\Workflow\Transition('transition_name', 'here', 'there'), 33 | Workflow::get($subject, 'straight') 34 | ); 35 | $event = TransitionEvent::newFromBase($baseEvent); 36 | $serialized = serialize($event); 37 | 38 | $this->assertIsString($serialized); 39 | 40 | $unserialized = unserialize($serialized); 41 | $this->assertInstanceOf(TransitionEvent::class, $unserialized); 42 | 43 | $this->assertEquals($subject, $unserialized->getSubject()); 44 | } 45 | 46 | /** 47 | * @test 48 | */ 49 | public function testGuardEventSerializesAndUnserializes() 50 | { 51 | $subject = new TestModel(); 52 | $event = new GuardEvent( 53 | $subject, 54 | new \Symfony\Component\Workflow\Marking(['here' => 1]), 55 | new \Symfony\Component\Workflow\Transition('transition_name', 'here', 'there'), 56 | Workflow::get($subject, 'straight') 57 | ); 58 | $serialized = serialize($event); 59 | 60 | $this->assertIsString($serialized); 61 | 62 | $unserialized = unserialize($serialized); 63 | $this->assertInstanceOf(GuardEvent::class, $unserialized); 64 | 65 | // Attempt a proxy method 66 | $this->assertInstanceOf(TransitionBlockerList::class, $unserialized->getTransitionBlockerList()); 67 | } 68 | 69 | /** 70 | * @test 71 | */ 72 | public function testGuardEventGuards() 73 | { 74 | $subject = new TestModel(); 75 | $symfonyEvent = new SymfonyGuardEvent( 76 | $subject, 77 | new \Symfony\Component\Workflow\Marking(['here' => 1]), 78 | new \Symfony\Component\Workflow\Transition('transition_name', 'here', 'there'), 79 | Workflow::get($subject, 'straight') 80 | ); 81 | 82 | $event = GuardEvent::newFromBase($symfonyEvent); 83 | 84 | $event->setBlocked(true); 85 | 86 | $this->assertTrue($event->isBlocked()); 87 | 88 | $blockerList = $event->getTransitionBlockerList(); 89 | $this->assertInstanceOf(TransitionBlockerList::class, $blockerList); 90 | $this->assertFalse($blockerList->isEmpty()); 91 | } 92 | 93 | /** 94 | * @test 95 | */ 96 | public function testQueueableEvents() 97 | { 98 | Event::listen('workflow.straight.test.transition.to_there', [TestWorkflowListener::class, 'handle']); 99 | $subject = app(TestEloquentModel::class); 100 | $workflow = Workflow::get($subject, 'straight.test'); 101 | $this->assertTrue($subject->workflow_can('to_there', 'straight.test')); 102 | $subject->workflow_apply('to_there', 'straight.test'); 103 | $this->assertEquals('there', $subject->marking); 104 | } 105 | 106 | protected function getPackageProviders($app) 107 | { 108 | return [WorkflowServiceProvider::class]; 109 | } 110 | 111 | protected function getPackageAliases($app) 112 | { 113 | return [ 114 | 'Workflow' => WorkflowFacade::class, 115 | ]; 116 | } 117 | 118 | /** 119 | * Define environment setup. 120 | * 121 | * @param \Illuminate\Foundation\Application $app 122 | * 123 | * @return void 124 | */ 125 | protected function getEnvironmentSetUp($app) 126 | { 127 | $app['config']['workflow'] = [ 128 | 'straight' => [ 129 | 'type' => 'workflow', 130 | 'marking_store' => [ 131 | 'type' => 'single_state', 132 | ], 133 | 'supports' => [ 134 | TestModel::class, 135 | ], 136 | 'places' => ['here', 'there', 'somewhere'], 137 | 'transitions' => [ 138 | 'to_there' => [ 139 | 'from' => 'here', 140 | 'to' => 'there', 141 | ], 142 | 'to_somewhere' => [ 143 | 'from' => 'there', 144 | 'to' => 'somewhere', 145 | ], 146 | ], 147 | ], 148 | 'straight.test' => [ 149 | 'type' => 'workflow', 150 | 'marking_store' => [ 151 | 'type' => 'single_state', 152 | ], 153 | 'supports' => [ 154 | TestEloquentModel::class, 155 | ], 156 | 'places' => ['here', 'there', 'somewhere'], 157 | 'transitions' => [ 158 | 'to_there' => [ 159 | 'from' => 'here', 160 | 'to' => 'there', 161 | ], 162 | 'to_somewhere' => [ 163 | 'from' => 'there', 164 | 'to' => 'somewhere', 165 | ], 166 | ], 167 | ], 168 | ]; 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /tests/Fixtures/TestCustomObject.php: -------------------------------------------------------------------------------- 1 | state; 12 | } 13 | 14 | public function setState($state) 15 | { 16 | $this->state = $state; 17 | 18 | return $this; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/Fixtures/TestEloquentModel.php: -------------------------------------------------------------------------------- 1 | attributes[$name] ?? null; 15 | } 16 | 17 | public function __set($name, $value) 18 | { 19 | $method = 'set' . ucfirst($name) . 'Attribute'; 20 | 21 | if (method_exists($this, $method)) { 22 | $this->$method($value); 23 | 24 | return; 25 | } 26 | 27 | $this->attributes[$name] = $value; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Fixtures/TestModelMutator.php: -------------------------------------------------------------------------------- 1 | attributes['marking'] = $value; 15 | $this->context = $context; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/Fixtures/TestObject.php: -------------------------------------------------------------------------------- 1 | marking; 12 | } 13 | 14 | public function setMarking($marking) 15 | { 16 | $this->marking = $marking; 17 | 18 | return $this; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/Fixtures/TestWorkflowListener.php: -------------------------------------------------------------------------------- 1 | getMethod($methodName); 22 | $method->setAccessible(true); 23 | 24 | return $method->invokeArgs($object, $parameters); 25 | } 26 | 27 | /** 28 | * Get a protected/private property on a class. 29 | * 30 | * @param object &$object Instantiated object to get the property from. 31 | * @param string $propertyName Property name to get. 32 | * 33 | * @return mixed Property value 34 | */ 35 | public function getProtectedProperty(&$object, $propertyName) 36 | { 37 | $property = (new ReflectionClass($object))->getProperty($propertyName); 38 | $property->setAccessible(true); 39 | 40 | return $property->getValue($object); 41 | } 42 | 43 | /** 44 | * Get a protected/private property on a class. 45 | * 46 | * @param object &$object Instantiated object to get the property from. 47 | * @param string $constantName 48 | * 49 | * @return mixed Property value 50 | */ 51 | public static function getProtectedConstant(&$object, $constantName) 52 | { 53 | return (new ReflectionClass($object))->getConstant($constantName); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/MarkingStores/EloquentMarkingStoreTest.php: -------------------------------------------------------------------------------- 1 | faker = \Faker\Factory::create(); 18 | } 19 | 20 | /** 21 | * @test 22 | * 23 | * @dataProvider providesSubjects 24 | * 25 | * @param mixed $subject 26 | */ 27 | public function testSingleStateMarking($subject) 28 | { 29 | $store = new EloquentMarkingStore(true, 'marking'); 30 | 31 | $subject->attributes['marking'] = $this->faker->unique()->word; 32 | 33 | $marking = $store->getMarking($subject); 34 | $this->assertInstanceOf(Marking::class, $marking); 35 | $this->assertEquals([$subject->attributes['marking'] => 1], $marking->getPlaces()); 36 | 37 | $newMarking = $this->faker->unique()->word; 38 | $store->setMarking($subject, new Marking([$newMarking => 1])); 39 | $setMarking = $store->getMarking($subject); 40 | $this->assertInstanceOf(Marking::class, $setMarking); 41 | $this->assertEquals([$newMarking => 1], $setMarking->getPlaces()); 42 | } 43 | 44 | public static function providesSubjects() 45 | { 46 | return [ 47 | [new TestModel()], 48 | [new TestModelMutator()], 49 | ]; 50 | } 51 | 52 | /** 53 | * @test 54 | * 55 | * @dataProvider providesSubjects 56 | * 57 | * @param mixed $subject 58 | */ 59 | public function testMultiStateMarking($subject) 60 | { 61 | $store = new EloquentMarkingStore(false, 'marking'); 62 | 63 | $subject->attributes['marking'] = array_combine($this->faker->words(3, false), [1,1,1]); 64 | 65 | $marking = $store->getMarking($subject); 66 | $this->assertInstanceOf(Marking::class, $marking); 67 | $this->assertEquals($subject->attributes['marking'], $marking->getPlaces()); 68 | 69 | $newMarking = array_combine($this->faker->words(3, false), [1,1,1]); 70 | $store->setMarking($subject, new Marking($newMarking)); 71 | $setMarking = $store->getMarking($subject); 72 | $this->assertInstanceOf(Marking::class, $setMarking); 73 | $this->assertEquals($newMarking, $setMarking->getPlaces()); 74 | } 75 | 76 | /** 77 | * @test 78 | * 79 | * @dataProvider providesTypeSafeScenarios 80 | * 81 | * @param mixed $markingValue 82 | * @param mixed $expectedMarkingValue 83 | * @param mixed $expectedMarkingKey 84 | */ 85 | public function testTypeSafeMarkings($markingValue, $expectedMarkingKey) 86 | { 87 | $store = new EloquentMarkingStore(true, 'marking'); 88 | 89 | $subject = new TestModel(); 90 | 91 | $subject->attributes['marking'] = $markingValue; 92 | 93 | $marking = $store->getMarking($subject); 94 | $this->assertInstanceOf(Marking::class, $marking); 95 | $this->assertEquals([$expectedMarkingKey => 1], $marking->getPlaces()); 96 | } 97 | 98 | public static function providesTypeSafeScenarios() 99 | { 100 | return [ 101 | [0, '0'], 102 | ['0', '0'], 103 | [false, ''], // ick 104 | ['false', 'false'], 105 | ]; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /tests/WorkflowDumpCommandTest.php: -------------------------------------------------------------------------------- 1 | getMock(workflow: 'fake'); 15 | 16 | $this->expectException(\Exception::class); 17 | $this->expectExceptionMessage('Workflow fake is not configured.'); 18 | 19 | $command->handle(); 20 | } 21 | 22 | public function testShouldThrowExceptionForUndefinedClass() 23 | { 24 | $command = $this->getMock(class: 'Tests\Fixtures\FakeObject'); 25 | 26 | $this->expectException(\Exception::class); 27 | $this->expectExceptionMessage('Workflow straight has no support for' . 28 | ' class Tests\Fixtures\FakeObject. Please specify a valid support' . 29 | ' class with the --class option.'); 30 | 31 | $command->handle(); 32 | } 33 | 34 | public function testWorkflowCommand() 35 | { 36 | $optionalPath = '/my/path'; 37 | $disk = 'public'; 38 | 39 | Storage::fake($disk); 40 | 41 | if (Storage::disk($disk)->exists($optionalPath . '/straight.png')) { 42 | Storage::disk($disk)->delete($optionalPath . '/straight.png'); 43 | } 44 | 45 | $command = $this->getMock(disk: $disk, path: $optionalPath); 46 | $command->handle(); 47 | 48 | Storage::disk($disk)->assertExists($optionalPath . '/straight.png'); 49 | } 50 | 51 | public function testWorkflowCommandWithMetadata() 52 | { 53 | $disk = 'public'; 54 | 55 | Storage::fake($disk); 56 | 57 | $command = $this->getMock( 58 | disk: $disk, 59 | format: 'svg', 60 | withMetadata: true, 61 | ); 62 | 63 | $command->handle(); 64 | 65 | Storage::disk($disk)->assertExists('straight.svg'); 66 | $svg_file = Storage::disk($disk)->get('straight.svg'); 67 | $this->assertStringContainsString('metadata_place', $svg_file); 68 | $this->assertStringContainsString('metadata_exists', $svg_file); 69 | } 70 | 71 | public function testWorkflowCommandWithoutMetadata() 72 | { 73 | $disk = 'public'; 74 | 75 | Storage::fake($disk); 76 | 77 | $command = $this->getMock( 78 | disk: $disk, 79 | format: 'svg', 80 | withMetadata: false, 81 | ); 82 | 83 | $command->handle(); 84 | 85 | Storage::disk($disk)->assertExists('straight.svg'); 86 | $svg_file = Storage::disk($disk)->get('straight.svg'); 87 | $this->assertStringContainsString('metadata_place', $svg_file); 88 | $this->assertStringNotContainsString('metadata_exists', $svg_file); 89 | } 90 | 91 | protected function getEnvironmentSetUp($app) 92 | { 93 | $app['config']['workflow'] = [ 94 | 'straight' => [ 95 | 'supports' => ['Tests\Fixtures\TestObject'], 96 | 'places' => [ 97 | 'a', 98 | 'b', 99 | 'c', 100 | 'metadata_place' => [ 101 | 'metadata' => [ 102 | 'metadata_exists' => true, 103 | ], 104 | ], 105 | ], 106 | 'transitions' => [ 107 | 't1' => [ 108 | 'from' => 'a', 109 | 'to' => 'b', 110 | ], 111 | 't2' => [ 112 | 'from' => 'b', 113 | 'to' => 'c', 114 | ], 115 | ], 116 | ], 117 | ]; 118 | } 119 | 120 | private function getMock( 121 | string $workflow = 'straight', 122 | string $format = 'png', 123 | string $class = 'Tests\Fixtures\TestObject', 124 | string $disk = 'local', 125 | string $path = '/', 126 | bool $withMetadata = false, 127 | ): MockInterface { 128 | return Mockery::mock(WorkflowDumpCommand::class) 129 | ->makePartial() 130 | ->shouldReceive('argument') 131 | ->with('workflow') 132 | ->andReturn($workflow) 133 | ->shouldReceive('option') 134 | ->with('format') 135 | ->andReturn($format) 136 | ->shouldReceive('option') 137 | ->with('class') 138 | ->andReturn($class) 139 | ->shouldReceive('option') 140 | ->with('disk') 141 | ->andReturn($disk) 142 | ->shouldReceive('option') 143 | ->with('path') 144 | ->andReturn($path) 145 | ->shouldReceive('option') 146 | ->with('with-metadata') 147 | ->andReturn($withMetadata) 148 | ->getMock(); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /tests/WorkflowEventsTest.php: -------------------------------------------------------------------------------- 1 | [ 23 | EnteredEvent::class, 24 | 'workflow.entered', 25 | 'workflow.%s.entered', 26 | ], 27 | 'guard' => [ 28 | GuardEvent::class, 29 | 'workflow.guard', 30 | 'workflow.%s.guard', 31 | 'workflow.%s.guard.%s', 32 | ], 33 | 'leave' => [ 34 | LeaveEvent::class, 35 | 'workflow.leave', 36 | 'workflow.%s.leave', 37 | 'workflow.%s.leave.%s', 38 | ], 39 | 'transition' => [ 40 | TransitionEvent::class, 41 | 'workflow.transition', 42 | 'workflow.%s.transition', 43 | 'workflow.%s.transition.%s', 44 | ], 45 | 'enter' => [ 46 | EnterEvent::class, 47 | 'workflow.enter', 48 | 'workflow.%s.enter', 49 | 'workflow.%s.enter.%s', 50 | ], 51 | 'entered' => [ 52 | EnteredEvent::class, 53 | 'workflow.entered', 54 | 'workflow.%s.entered', 55 | 'workflow.%s.entered.%s', 56 | ], 57 | 'completed' => [ 58 | CompletedEvent::class, 59 | 'workflow.completed', 60 | 'workflow.%s.completed', 61 | 'workflow.%s.completed.%s', 62 | ], 63 | 'announce' => [ 64 | AnnounceEvent::class, 65 | 'workflow.announce', 66 | 'workflow.%s.announce', 67 | ], 68 | ]; 69 | 70 | /** 71 | * @test 72 | */ 73 | public function testIfWorkflowEmitsEvents() 74 | { 75 | Event::fake(); 76 | 77 | $config = [ 78 | 'straight' => [ 79 | 'supports' => [TestObject::class], 80 | 'places' => ['a', 'b', 'c'], 81 | 'transitions' => [ 82 | 't1' => [ 83 | 'from' => 'a', 84 | 'to' => 'b', 85 | ], 86 | 't2' => [ 87 | 'from' => 'b', 88 | 'to' => 'c', 89 | ], 90 | ], 91 | ], 92 | ]; 93 | 94 | $registry = new WorkflowRegistry($config, null, $this->app->make(EventsDispatcher::class)); 95 | $object = new TestObject(); 96 | $workflow = $registry->get($object); 97 | 98 | $workflow->apply($object, 't1'); 99 | 100 | // Symfony Workflow 4.2.9 fires entered event on initialize 101 | $this->assertEventSetDispatched('workflow_enter'); 102 | 103 | $this->assertEventSetDispatched('guard', 't1'); 104 | 105 | $this->assertEventSetDispatched('leave', 'a'); 106 | 107 | $this->assertEventSetDispatched('transition', 't1'); 108 | 109 | $this->assertEventSetDispatched('enter', 'b'); 110 | 111 | $this->assertEventSetDispatched('entered', 'b'); 112 | 113 | $this->assertEventSetDispatched('completed', 't1'); 114 | 115 | // Announce happens after completed 116 | $this->assertEventSetDispatched('announce', 't1'); 117 | Event::assertDispatched('workflow.straight.announce.t2'); 118 | 119 | $this->assertEventSetDispatched('guard', 't2'); 120 | } 121 | 122 | /** 123 | * @test 124 | * 125 | * @dataProvider providesEventsToDispatchScenarios 126 | */ 127 | public function testIfWorkflowOnlyEmitsSpecificEvents(?array $eventsToDispatch, array $eventsToExpect) 128 | { 129 | Event::fake(); 130 | 131 | $config = [ 132 | 'straight' => [ 133 | 'supports' => [TestObject::class], 134 | 'places' => ['a', 'b', 'c'], 135 | 'events_to_dispatch' => $eventsToDispatch, 136 | 'transitions' => [ 137 | 't1' => [ 138 | 'from' => 'a', 139 | 'to' => 'b', 140 | ], 141 | 't2' => [ 142 | 'from' => 'b', 143 | 'to' => 'c', 144 | ], 145 | ], 146 | ], 147 | ]; 148 | 149 | $registry = new WorkflowRegistry($config, null, $this->app->make(EventsDispatcher::class)); 150 | $object = new TestObject(); 151 | $workflow = $registry->get($object); 152 | 153 | $workflow->apply($object, 't1'); 154 | 155 | // Ignoring guard since it's always dispatched 156 | $this->assertEventSetDispatched('workflow_enter', null, in_array('entered', $eventsToExpect)); 157 | $this->assertEventSetDispatched('leave', 'a', in_array('leave', $eventsToExpect)); 158 | $this->assertEventSetDispatched('transition', 't1', in_array('transition', $eventsToExpect)); 159 | $this->assertEventSetDispatched('enter', 'b', in_array('enter', $eventsToExpect)); 160 | $this->assertEventSetDispatched('entered', 'b', in_array('entered', $eventsToExpect)); 161 | $this->assertEventSetDispatched('completed', 't1', in_array('completed', $eventsToExpect)); 162 | $this->assertEventSetDispatched('announce', 't1', in_array('announce', $eventsToExpect)); 163 | } 164 | 165 | public static function providesEventsToDispatchScenarios() 166 | { 167 | $events = [ 168 | 'enter' => WorkflowEvents::ENTER, 169 | 'leave' => WorkflowEvents::LEAVE, 170 | 'transition' => WorkflowEvents::TRANSITION, 171 | 'entered' => WorkflowEvents::ENTERED, 172 | 'completed' => WorkflowEvents::COMPLETED, 173 | 'announce' => WorkflowEvents::ANNOUNCE, 174 | ]; 175 | 176 | yield 'null events dispatches all' => [ 177 | null, array_keys($events), 178 | ]; 179 | 180 | yield 'empty events dispatches none' => [ 181 | [], [], 182 | ]; 183 | 184 | foreach ($events as $key => $event) { 185 | yield "silences {$event}" => [[$event], [$key]]; 186 | } 187 | } 188 | 189 | /** 190 | * @test 191 | */ 192 | public function testIfWorkflowEmitsEventsWithContext() 193 | { 194 | Event::fake(); 195 | 196 | $config = [ 197 | 'straight' => [ 198 | 'supports' => [TestObject::class], 199 | 'places' => ['a', 'b', 'c'], 200 | 'transitions' => [ 201 | 't1' => [ 202 | 'from' => 'a', 203 | 'to' => 'b', 204 | ], 205 | 't2' => [ 206 | 'from' => 'b', 207 | 'to' => 'c', 208 | ], 209 | ], 210 | ], 211 | ]; 212 | 213 | $registry = new WorkflowRegistry($config, null, $this->app->make(EventsDispatcher::class)); 214 | $object = new TestObject(); 215 | $workflow = $registry->get($object); 216 | 217 | $context = ['context1' => 42, 'context2' => 'banana']; 218 | 219 | $workflow->apply($object, 't1', $context); 220 | 221 | // Symfony Workflow 4.2.9 fires entered event on initialize 222 | Event::assertDispatched(function (EnteredEvent $event) use ($object) { 223 | return $event->getSubject() == $object; 224 | }); 225 | Event::assertDispatched(function (LeaveEvent $event) use ($object) { 226 | return $event->getSubject() == $object; 227 | }); 228 | Event::assertDispatched(function (TransitionEvent $event) use ($object) { 229 | return $event->getSubject() == $object; 230 | }); 231 | Event::assertDispatched(function (EnterEvent $event) use ($object) { 232 | return $event->getSubject() == $object; 233 | }); 234 | Event::assertDispatched(function (CompletedEvent $event) use ($object) { 235 | return $event->getSubject() == $object; 236 | }); 237 | Event::assertDispatched(function (AnnounceEvent $event) use ($object) { 238 | return $event->getSubject() == $object; 239 | }); 240 | } 241 | 242 | /** 243 | * @test 244 | */ 245 | public function testWorkflowGuardEventsBlockTransition() 246 | { 247 | $config = [ 248 | 'straight' => [ 249 | 'supports' => [TestObject::class], 250 | 'places' => ['a', 'b', 'c'], 251 | 'transitions' => [ 252 | 't1' => [ 253 | 'from' => 'a', 254 | 'to' => 'b', 255 | ], 256 | 't2' => [ 257 | 'from' => 'b', 258 | 'to' => 'c', 259 | ], 260 | ], 261 | ], 262 | ]; 263 | 264 | $registry = new WorkflowRegistry($config, null, $this->app->make(EventsDispatcher::class)); 265 | $object = new TestObject(); 266 | $workflow = $registry->get($object); 267 | 268 | Event::listen('workflow.straight.guard.t1', function ($event) { 269 | $event->setBlocked(true); 270 | }); 271 | 272 | $this->assertFalse($workflow->can($object, 't1')); 273 | 274 | $this->expectException(NotEnabledTransitionException::class); 275 | $workflow->apply($object, 't1'); 276 | } 277 | 278 | private function assertEventSetDispatched(string $eventSet, ?string $arg = null, bool $expected = true) 279 | { 280 | $workflow = 'straight'; 281 | 282 | $method = ($expected) ? 'assertDispatched' : 'assertNotDispatched'; 283 | 284 | foreach ($this->eventSets[$eventSet] as $event) { 285 | Event::$method(sprintf($event, $workflow, $arg)); 286 | } 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /tests/WorkflowMetadataTest.php: -------------------------------------------------------------------------------- 1 | [ 16 | 'metadata' => [ 17 | 'title' => 'test title', 18 | ], 19 | 'supports' => ['Tests\Fixtures\TestObject'], 20 | 'places' => [ 21 | 'a', 22 | 'b' => [ 23 | 'metadata' => [ 24 | 'm1' => 'forks', 25 | ], 26 | ], 27 | 'c' => [ 28 | 'metadata' => [ 29 | 'm2' => 'spoons', 30 | ], 31 | ], 32 | ], 33 | 'transitions' => [ 34 | 't1' => [ 35 | 'from' => 'a', 36 | 'to' => 'b', 37 | 'metadata' => [ 38 | 'm3' => 'knives', 39 | ], 40 | ], 41 | 't2' => [ 42 | 'from' => 'b', 43 | 'to' => 'c', 44 | ], 45 | ], 46 | ], 47 | ]; 48 | 49 | $registry = new WorkflowRegistry($config, null, new Dispatcher()); 50 | $subject = new TestObject(); 51 | $workflow = $registry->get($subject); 52 | 53 | $this->assertEquals( 54 | $config['straight']['metadata']['title'], 55 | $workflow->getMetadataStore()->getWorkflowMetadata()['title'] 56 | ); 57 | 58 | $this->assertEquals( 59 | $config['straight']['places']['b']['metadata']['m1'], 60 | $workflow->getMetadataStore()->getPlaceMetadata('b')['m1'] 61 | ); 62 | $this->assertEquals( 63 | $config['straight']['places']['c']['metadata']['m2'], 64 | $workflow->getMetadataStore()->getPlaceMetadata('c')['m2'] 65 | ); 66 | 67 | $this->assertEquals( 68 | $config['straight']['transitions']['t1']['metadata']['m3'], 69 | $workflow->getMetadataStore()->getTransitionMetadata( 70 | $workflow->getDefinition()->getTransitions()[0] 71 | )['m3'] 72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /tests/WorkflowRegistryTest.php: -------------------------------------------------------------------------------- 1 | [ 34 | 'supports' => ['Tests\Fixtures\TestObject'], 35 | 'places' => ['a', 'b', 'c'], 36 | 'transitions' => [ 37 | 't1' => [ 38 | 'from' => 'a', 39 | 'to' => 'b', 40 | ], 41 | 't2' => [ 42 | 'from' => 'b', 43 | 'to' => 'c', 44 | ], 45 | ], 46 | ], 47 | ]; 48 | 49 | $registry = new WorkflowRegistry($config, null, new Dispatcher()); 50 | $subject = new TestObject(); 51 | $workflow = $registry->get($subject); 52 | 53 | $markingStoreProp = new ReflectionProperty(Workflow::class, 'markingStore'); 54 | $markingStoreProp->setAccessible(true); 55 | 56 | $markingStore = $markingStoreProp->getValue($workflow); 57 | 58 | $this->assertInstanceof(Workflow::class, $workflow); 59 | $this->assertInstanceof(EloquentMarkingStore::class, $markingStore); 60 | } 61 | 62 | /** 63 | * @test 64 | */ 65 | public function testIfStateMachineIsRegistered() 66 | { 67 | $config = [ 68 | 'straight' => [ 69 | 'type' => 'state_machine', 70 | 'marking_store' => [ 71 | 'type' => 'multiple_state', 72 | ], 73 | 'supports' => ['Tests\Fixtures\TestObject'], 74 | 'places' => ['a', 'b', 'c'], 75 | 'transitions' => [ 76 | 't1' => [ 77 | 'from' => 'a', 78 | 'to' => 'b', 79 | ], 80 | 't2' => [ 81 | 'from' => 'b', 82 | 'to' => 'c', 83 | ], 84 | ], 85 | ], 86 | ]; 87 | 88 | $registry = new WorkflowRegistry($config, null, new Dispatcher()); 89 | $subject = new TestObject(); 90 | $workflow = $registry->get($subject); 91 | 92 | $markingStoreProp = new ReflectionProperty(Workflow::class, 'markingStore'); 93 | $markingStoreProp->setAccessible(true); 94 | 95 | $markingStore = $markingStoreProp->getValue($workflow); 96 | 97 | $this->assertInstanceOf(StateMachine::class, $workflow); 98 | $this->assertInstanceOf(EloquentMarkingStore::class, $markingStore); 99 | } 100 | 101 | /** 102 | * @test 103 | */ 104 | public function testEloquentMarkingStoreIsRegistered() 105 | { 106 | $config = [ 107 | 'straight' => [ 108 | 'type' => 'state_machine', 109 | 'marking_store' => [ 110 | 'type' => 'multiple_state', 111 | 'class' => MethodMarkingStore::class, 112 | ], 113 | 'supports' => ['Tests\Fixtures\TestObject'], 114 | 'places' => ['a', 'b', 'c'], 115 | 'transitions' => [ 116 | 't1' => [ 117 | 'from' => 'a', 118 | 'to' => 'b', 119 | ], 120 | 't2' => [ 121 | 'from' => 'b', 122 | 'to' => 'c', 123 | ], 124 | ], 125 | ], 126 | ]; 127 | 128 | $registry = new WorkflowRegistry($config, null, new Dispatcher()); 129 | $subject = new TestObject(); 130 | $workflow = $registry->get($subject); 131 | 132 | $markingStoreProp = new ReflectionProperty(Workflow::class, 'markingStore'); 133 | $markingStoreProp->setAccessible(true); 134 | 135 | $markingStore = $markingStoreProp->getValue($workflow); 136 | 137 | $this->assertInstanceOf(StateMachine::class, $workflow); 138 | $this->assertInstanceOf(MethodMarkingStore::class, $markingStore); 139 | } 140 | 141 | /** 142 | * @test 143 | */ 144 | public function testIfTransitionsWithSameNameCanBothBeUsed() 145 | { 146 | $config = [ 147 | 'straight' => [ 148 | 'type' => 'state_machine', 149 | 'supports' => ['Tests\Fixtures\TestObject'], 150 | 'places' => ['a', 'b', 'c'], 151 | 'transitions' => [ 152 | [ 153 | 'name' => 't1', 154 | 'from' => 'a', 155 | 'to' => 'b', 156 | ], 157 | [ 158 | 'name' => 't1', 159 | 'from' => 'c', 160 | 'to' => 'b', 161 | ], 162 | [ 163 | 'name' => 't2', 164 | 'from' => 'b', 165 | 'to' => 'c', 166 | ], 167 | ], 168 | ], 169 | ]; 170 | 171 | $registry = new WorkflowRegistry($config, null, new Dispatcher()); 172 | $subject = new TestObject(); 173 | $workflow = $registry->get($subject); 174 | 175 | $markingStoreProp = new ReflectionProperty(Workflow::class, 'markingStore'); 176 | $markingStoreProp->setAccessible(true); 177 | 178 | $markingStore = $markingStoreProp->getValue($workflow); 179 | 180 | $this->assertInstanceof(StateMachine::class, $workflow); 181 | $this->assertInstanceof(EloquentMarkingStore::class, $markingStore); 182 | $this->assertTrue($workflow->can($subject, 't1')); 183 | 184 | $workflow->apply($subject, 't1'); 185 | $workflow->apply($subject, 't2'); 186 | 187 | $this->assertTrue($workflow->can($subject, 't1')); 188 | } 189 | 190 | /** 191 | * @test 192 | */ 193 | public function testWhenMultipleFromIsUsedStateMachine() 194 | { 195 | $config = [ 196 | 'straight' => [ 197 | 'type' => 'state_machine', 198 | 'supports' => ['Tests\Fixtures\TestObject'], 199 | 'places' => ['a', 'b', 'c'], 200 | 'transitions' => [ 201 | [ 202 | 'name' => 't1', 203 | 'from' => 'a', 204 | 'to' => 'b', 205 | ], 206 | [ 207 | 'name' => 't2', 208 | 'from' => [ 209 | 'a', 210 | 'b', 211 | ], 212 | 'to' => 'c', 213 | ], 214 | ], 215 | ], 216 | ]; 217 | 218 | $registry = new WorkflowRegistry($config, null, new Dispatcher()); 219 | $subject = new TestObject(); 220 | $workflow = $registry->get($subject); 221 | 222 | $markingStoreProp = new ReflectionProperty(Workflow::class, 'markingStore'); 223 | $markingStoreProp->setAccessible(true); 224 | 225 | $markingStore = $markingStoreProp->getValue($workflow); 226 | 227 | $this->assertInstanceof(StateMachine::class, $workflow); 228 | $this->assertInstanceof(EloquentMarkingStore::class, $markingStore); 229 | $this->assertTrue($workflow->can($subject, 't1')); 230 | $this->assertTrue($workflow->can($subject, 't2')); 231 | } 232 | 233 | /** 234 | * @test 235 | */ 236 | public function testWhenMultipleFromIsUsedWorkflow() 237 | { 238 | $config = [ 239 | 'straight' => [ 240 | 'type' => 'workflow', 241 | 'supports' => ['Tests\Fixtures\TestObject'], 242 | 'places' => ['a', 'b', 'c', 'd'], 243 | 'transitions' => [ 244 | [ 245 | 'name' => 't1', 246 | 'from' => 'a', 247 | 'to' => ['b','c'], 248 | ], 249 | [ 250 | 'name' => 't2', 251 | 'from' => [ 252 | ['b','c'], 253 | ], 254 | 'to' => 'd', 255 | ], 256 | ], 257 | ], 258 | ]; 259 | 260 | $registry = new WorkflowRegistry($config, null, new Dispatcher()); 261 | $subject = new TestObject(); 262 | $workflow = $registry->get($subject); 263 | 264 | $markingStoreProp = new ReflectionProperty(Workflow::class, 'markingStore'); 265 | $markingStoreProp->setAccessible(true); 266 | 267 | $markingStore = $markingStoreProp->getValue($workflow); 268 | 269 | $this->assertInstanceof(Workflow::class, $workflow); 270 | $this->assertInstanceof(EloquentMarkingStore::class, $markingStore); 271 | $this->assertTrue($workflow->can($subject, 't1')); 272 | $this->assertFalse($workflow->can($subject, 't2')); 273 | 274 | $workflow->apply($subject, 't1'); 275 | $this->assertTrue($workflow->can($subject, 't2')); 276 | $this->assertFalse($workflow->can($subject, 't1')); 277 | } 278 | 279 | /** 280 | * @test 281 | */ 282 | public function testIfInitialPlaceIsRegistered() 283 | { 284 | $config = [ 285 | 'straight' => [ 286 | 'supports' => ['Tests\Fixtures\TestObject'], 287 | 'places' => ['a', 'b', 'c'], 288 | 'transitions' => [ 289 | 't1' => [ 290 | 'from' => 'c', 291 | 'to' => 'b', 292 | ], 293 | 't2' => [ 294 | 'from' => 'b', 295 | 'to' => 'a', 296 | ], 297 | ], 298 | 'initial_places' => 'c', 299 | ], 300 | ]; 301 | 302 | $registry = new WorkflowRegistry($config, null, new Dispatcher()); 303 | $subject = new TestObject(); 304 | $workflow = $registry->get($subject); 305 | 306 | $markingStoreProp = new ReflectionProperty(Workflow::class, 'markingStore'); 307 | $markingStoreProp->setAccessible(true); 308 | 309 | $markingStore = $markingStoreProp->getValue($workflow); 310 | 311 | $this->assertInstanceof(Workflow::class, $workflow); 312 | $this->assertInstanceof(EloquentMarkingStore::class, $markingStore); 313 | 314 | $this->assertEquals(['c'], $workflow->getDefinition()->getInitialPlaces()); 315 | } 316 | 317 | /** 318 | * @test 319 | */ 320 | public function testIfCustomMarkingPropertyIsUsed() 321 | { 322 | $config = [ 323 | 'straight' => [ 324 | 'supports' => ['Tests\Fixtures\TestCustomObject'], 325 | 'places' => ['a', 'b', 'c'], 326 | 'marking_store' => [ 327 | 'type' => 'single_state', 328 | 'property' => 'state', 329 | ], 330 | 'transitions' => [ 331 | 't1' => [ 332 | 'from' => 'c', 333 | 'to' => 'b', 334 | ], 335 | 't2' => [ 336 | 'from' => 'b', 337 | 'to' => 'a', 338 | ], 339 | ], 340 | 'initial_places' => 'c', 341 | ], 342 | ]; 343 | 344 | $registry = new WorkflowRegistry($config, null, new Dispatcher()); 345 | $subject = new TestCustomObject(); 346 | $workflow = $registry->get($subject); 347 | 348 | $markingStoreProp = new ReflectionProperty(Workflow::class, 'markingStore'); 349 | $markingStoreProp->setAccessible(true); 350 | 351 | $markingStore = $markingStoreProp->getValue($workflow); 352 | 353 | $this->assertInstanceof(Workflow::class, $workflow); 354 | $this->assertInstanceof(EloquentMarkingStore::class, $markingStore); 355 | $this->assertTrue($workflow->can($subject, 't1')); 356 | 357 | $workflow->apply($subject, 't1'); 358 | 359 | $this->assertEquals('b', $subject->getState()); 360 | } 361 | 362 | /** 363 | * @test 364 | * 365 | * @dataProvider providesAutomaticMarkingStoreScenarios 366 | */ 367 | public function testIfMarkingStoreIsAutomatic(array $typeConfig, bool $expectSingleState) 368 | { 369 | $config = [ 370 | 'test' => array_merge([ 371 | 'supports' => ['Tests\Fixtures\TestObject'], 372 | 'places' => ['a', 'b', 'c'], 373 | 'transitions' => [ 374 | 't1' => [ 375 | 'from' => 'a', 376 | 'to' => 'b', 377 | ], 378 | 't2' => [ 379 | 'from' => 'b', 380 | 'to' => 'c', 381 | ], 382 | ], 383 | ], $typeConfig), 384 | ]; 385 | 386 | $registry = new WorkflowRegistry($config, null, new Dispatcher()); 387 | $subject = new TestObject(); 388 | $workflow = $registry->get($subject); 389 | 390 | $markingStoreProp = new ReflectionProperty(Workflow::class, 'markingStore'); 391 | $markingStoreProp->setAccessible(true); 392 | 393 | $markingStore = $markingStoreProp->getValue($workflow); 394 | 395 | $this->assertInstanceof(Workflow::class, $workflow); 396 | $this->assertInstanceof(EloquentMarkingStore::class, $markingStore); 397 | $this->assertEquals($expectSingleState, $this->getProtectedProperty($markingStore, 'singleState')); 398 | } 399 | 400 | public static function providesAutomaticMarkingStoreScenarios() 401 | { 402 | return [ 403 | 'default workflow, default multi' => [[], false], 404 | 'set workflow, default multi' => [[ 405 | 'type' => 'workflow', 406 | ], false], 407 | 'set workflow, override single' => [[ 408 | 'type' => 'workflow', 409 | 'marking_store' => [ 410 | 'type' => 'single_state', 411 | ], 412 | ], true], 413 | 'set workflow, override multiple' => [[ 414 | 'type' => 'workflow', 415 | 'marking_store' => [ 416 | 'type' => 'multiple_state', 417 | ], 418 | ], false], 419 | 'set state machine, default single' => [[ 420 | 'type' => 'state_machine', 421 | ], true], 422 | 'set state machine, override multi' => [[ 423 | 'type' => 'state_machine', 424 | 'marking_store' => [ 425 | 'type' => 'multiple_state', 426 | ], 427 | ], false], 428 | 'set state machine, override single' => [[ 429 | 'type' => 'state_machine', 430 | 'marking_store' => [ 431 | 'type' => 'single_state', 432 | ], 433 | ], true], 434 | ]; 435 | } 436 | } 437 | -------------------------------------------------------------------------------- /tests/WorkflowTrackingTest.php: -------------------------------------------------------------------------------- 1 | [ 18 | 'supports' => ['Tests\Fixtures\TestObject'], 19 | 'places' => ['a', 'b', 'c'], 20 | 'transitions' => [ 21 | 't1' => [ 22 | 'from' => 'a', 23 | 'to' => 'b', 24 | ], 25 | 't2' => [ 26 | 'from' => 'b', 27 | 'to' => 'c', 28 | ], 29 | ], 30 | ], 31 | ]; 32 | 33 | $registryConfig = [ 34 | 'track_loaded' => true, 35 | 'ignore_duplicates' => false, 36 | ]; 37 | 38 | $registry = new WorkflowRegistry($config, $registryConfig, new Dispatcher()); 39 | $subject = new TestObject(); 40 | $workflow = $registry->get($subject); 41 | 42 | $this->expectException(DuplicateWorkflowException::class); 43 | $registry->addFromArray('straight', $config['straight']); 44 | } 45 | 46 | public function testIfAllowDuplicates() 47 | { 48 | $config = [ 49 | 'straight' => [ 50 | 'supports' => ['Tests\Fixtures\TestObject'], 51 | 'places' => ['a', 'b', 'c'], 52 | 'transitions' => [ 53 | 't1' => [ 54 | 'from' => 'a', 55 | 'to' => 'b', 56 | ], 57 | 't2' => [ 58 | 'from' => 'b', 59 | 'to' => 'c', 60 | ], 61 | ], 62 | ], 63 | ]; 64 | 65 | $registryConfig = [ 66 | 'track_loaded' => true, 67 | 'ignore_duplicates' => true, 68 | ]; 69 | 70 | $registry = new WorkflowRegistry($config, $registryConfig, new Dispatcher()); 71 | $subject = new TestObject(); 72 | $workflow = $registry->get($subject); 73 | 74 | $registry->addFromArray('straight', $config['straight']); 75 | 76 | $this->assertCount(1, $registry->getLoaded('Tests\Fixtures\TestObject')); 77 | } 78 | 79 | public function testIfGetLoadedWithoutTracking() 80 | { 81 | $config = [ 82 | 'straight' => [ 83 | 'supports' => ['Tests\Fixtures\TestObject'], 84 | 'places' => ['a', 'b', 'c'], 85 | 'transitions' => [ 86 | 't1' => [ 87 | 'from' => 'a', 88 | 'to' => 'b', 89 | ], 90 | 't2' => [ 91 | 'from' => 'b', 92 | 'to' => 'c', 93 | ], 94 | ], 95 | ], 96 | ]; 97 | 98 | $registryConfig = [ 99 | 'track_loaded' => false, 100 | 'ignore_duplicates' => true, 101 | ]; 102 | 103 | $registry = new WorkflowRegistry($config, $registryConfig, new Dispatcher()); 104 | $subject = new TestObject(); 105 | $workflow = $registry->get($subject); 106 | 107 | $this->expectException(RegistryNotTrackedException::class); 108 | $registry->getLoaded(); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /tests/WorkflowTraitTest.php: -------------------------------------------------------------------------------- 1 | app->instance('workflow', $registryMock); 23 | 24 | $registryMock->shouldReceive('get') 25 | ->once() 26 | ->with($this, 'workflow17') 27 | ->andReturn($workflowMock); 28 | 29 | $workflowMock->shouldReceive('apply') 30 | ->once() 31 | ->with($this, 't1', ['banana' => 42]); 32 | 33 | $this->workflow_apply('t1', 'workflow17', ['banana' => 42]); 34 | } 35 | 36 | /** 37 | * @test 38 | */ 39 | public function testWorkflowApplyDuckTyped() 40 | { 41 | $registryMock = Mockery::mock(WorkflowRegistry::class); 42 | $workflowMock = Mockery::mock(SymfonyWorkflow::class); 43 | 44 | $this->app->instance('workflow', $registryMock); 45 | 46 | $registryMock->shouldReceive('get') 47 | ->once() 48 | ->with($this, null) 49 | ->andReturn($workflowMock); 50 | 51 | $workflowMock->shouldReceive('apply') 52 | ->once() 53 | ->with($this, 't1', ['banana' => 42]); 54 | 55 | $this->workflow_apply('t1', ['banana' => 42]); 56 | } 57 | } 58 | --------------------------------------------------------------------------------