├── .codeclimate.yml ├── .coveralls.yml ├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── phpmd.xml ├── phpunit.xml ├── src ├── Exception │ ├── WorkflowControl │ │ ├── BreakException.php │ │ ├── ContinueException.php │ │ ├── ControlException.php │ │ ├── FailStepException.php │ │ ├── FailWorkflowException.php │ │ ├── LoopControlException.php │ │ ├── SkipStepException.php │ │ └── SkipWorkflowException.php │ ├── WorkflowException.php │ ├── WorkflowStepDependencyNotFulfilledException.php │ └── WorkflowValidationException.php ├── ExecutableWorkflow.php ├── Middleware │ ├── ProfileStep.php │ └── WorkflowStepDependencyCheck.php ├── Stage │ ├── After.php │ ├── Before.php │ ├── MultiStepStage.php │ ├── Next │ │ ├── AllowNextAfter.php │ │ ├── AllowNextBefore.php │ │ ├── AllowNextExecuteWorkflow.php │ │ ├── AllowNextOnError.php │ │ ├── AllowNextOnSuccess.php │ │ ├── AllowNextPrepare.php │ │ ├── AllowNextProcess.php │ │ └── AllowNextValidator.php │ ├── OnError.php │ ├── OnSuccess.php │ ├── Prepare.php │ ├── Process.php │ ├── Stage.php │ └── Validate.php ├── State │ ├── ExecutionLog │ │ ├── Describable.php │ │ ├── ExecutionLog.php │ │ ├── OutputFormat │ │ │ ├── GraphViz.php │ │ │ ├── OutputFormat.php │ │ │ ├── StringLog.php │ │ │ └── WorkflowGraph.php │ │ ├── Step.php │ │ ├── StepInfo.php │ │ └── Summary.php │ ├── NestedContainer.php │ ├── WorkflowContainer.php │ ├── WorkflowResult.php │ └── WorkflowState.php ├── Step │ ├── Dependency │ │ ├── Requires.php │ │ └── StepDependencyInterface.php │ ├── Loop.php │ ├── LoopControl.php │ ├── NestedWorkflow.php │ ├── StepExecutionTrait.php │ └── WorkflowStep.php ├── Validator.php ├── Workflow.php └── WorkflowControl.php └── tests ├── LoopTest.php ├── NestedWorkflowTest.php ├── OutputFormatterTest.php ├── StepDependencyTest.php ├── WorkflowContainerTest.php ├── WorkflowSetupTrait.php ├── WorkflowTest.php └── WorkflowTestTrait.php /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | phpmd: 3 | enabled: true 4 | config: 5 | file_extensions: "php" 6 | rulesets: "phpmd.xml" 7 | phpcodesniffer: 8 | enabled: true 9 | phan: 10 | enabled: true 11 | config: 12 | file_extensions: "php" 13 | ignore-undeclared: true 14 | sonar-php: 15 | enabled: true 16 | config: 17 | tests_patterns: 18 | - tests/** 19 | -------------------------------------------------------------------------------- /.coveralls.yml: -------------------------------------------------------------------------------- 1 | service_name: github-actions 2 | coverage_clover: build/logs/clover.xml 3 | json_path: build/logs/coveralls-upload.json -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | tests: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | php: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] 11 | 12 | name: PHP ${{ matrix.php }} tests 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | 17 | - name: Setup PHP 18 | uses: shivammathur/setup-php@v2 19 | with: 20 | php-version: ${{ matrix.php }} 21 | coverage: xdebug 22 | 23 | - name: Install dependencies 24 | run: composer install 25 | 26 | - name: Prepare codeclimate test reporter 27 | if: ${{ matrix.php == '8.0' }} 28 | run: | 29 | curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 30 | chmod +x ./cc-test-reporter 31 | ./cc-test-reporter before-build 32 | 33 | - name: Execute tests 34 | run: XDEBUG_MODE=coverage ./vendor/bin/phpunit --coverage-clover=build/logs/clover.xml --testdox 35 | 36 | - name: Upload the reports to coveralls.io 37 | if: ${{ matrix.php == '8.0' }} 38 | run: | 39 | composer global require php-coveralls/php-coveralls 40 | php-coveralls -v 41 | env: 42 | COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | 44 | - name: Upload the reports to codeclimate 45 | if: ${{ matrix.php == '8.0' }} 46 | run: sudo ./cc-test-reporter after-build -r $CC_TEST_REPORTER_ID 47 | env: 48 | CC_TEST_REPORTER_ID: 2d364fb3d1b91ff1946425afbdc5bb1eb7dad8e02454cbd92cbb1a8fb661a439 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | .idea 3 | composer.lock 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Enno Woortmann 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 | [![Latest Version](https://img.shields.io/packagist/v/wol-soft/php-workflow.svg)](https://packagist.org/packages/wol-soft/php-workflow) 2 | [![Minimum PHP Version](https://img.shields.io/badge/php-%3E%3D%207.4-8892BF.svg)](https://php.net/) 3 | [![Maintainability](https://api.codeclimate.com/v1/badges/a7c6d1c276d2a6aba61e/maintainability)](https://codeclimate.com/github/wol-soft/php-workflow/maintainability) 4 | [![Build Status](https://github.com/wol-soft/php-workflow/actions/workflows/main.yml/badge.svg)](https://github.com/wol-soft/php-workflow/actions/workflows/main.yml) 5 | [![Coverage Status](https://coveralls.io/repos/github/wol-soft/php-workflow/badge.svg)](https://coveralls.io/github/wol-soft/php-workflow) 6 | [![MIT License](https://img.shields.io/packagist/l/wol-soft/php-workflow.svg)](https://github.com/wol-soft/php-workflow/blob/master/LICENSE) 7 | 8 | # php-workflow 9 | 10 | Create controlled workflows from small pieces. 11 | 12 | This library provides a predefined set of stages to glue together your workflows. 13 | You implement small self-contained pieces of code and define the execution order - everything else will be done by the execution control. 14 | 15 | Bonus: you will get an execution log for each executed workflow - if you want to see what's happening. 16 | 17 | ## Table of Contents ## 18 | 19 | * [Workflow vs. process](#Workflow-vs-process) 20 | * [Installation](#Installation) 21 | * [Example workflow](#Example-workflow) 22 | * [Workflow container](#Workflow-container) 23 | * [Stages](#Stages) 24 | * [Workflow control](#Workflow-control) 25 | * [Nested workflows](#Nested-workflows) 26 | * [Loops](#Loops) 27 | * [Step dependencies](#Step-dependencies) 28 | * [Required container values](#Required-container-values) 29 | * [Error handling, logging and debugging](#Error-handling-logging-and-debugging) 30 | * [Custom output formatter](#Custom-output-formatter) 31 | * [Tests](#Tests) 32 | * [Workshop](#Workshop) 33 | 34 | ## Workflow vs. process 35 | 36 | Before we start to look at coding with the library let's have a look, what a workflow implemented with this library can and what a workflow can't. 37 | 38 | Let's assume we want to sell an item via an online shop. 39 | If a customer purchases an item he walks through the process of purchasing an item. 40 | This process contains multiple steps. 41 | Each process step can be represented by a workflow implemented with this library. For example: 42 | 43 | * Customer registration 44 | * Add items to the basket 45 | * Checkout the basket 46 | * ... 47 | 48 | This library helps you to implement the process steps in a structured way. 49 | It doesn't control the process flow. 50 | 51 | Now we know which use cases this library aims at. Now let's install the library and start coding. 52 | 53 | ## Installation 54 | 55 | The recommended way to install php-workflow is through [Composer](http://getcomposer.org): 56 | 57 | ``` 58 | $ composer require wol-soft/php-workflow 59 | ``` 60 | 61 | Requirements of the library: 62 | 63 | - Requires at least PHP 7.4 64 | 65 | ## Example workflow 66 | 67 | Let's have a look at a code example first. 68 | Our example will represent the code to add a song to a playlist in a media player. 69 | Casually you will have a controller method which glues together all necessary steps with many if's, returns, try-catch blocks and so on. 70 | Now let's have a look at a possible workflow definition: 71 | 72 | ```php 73 | $workflowResult = (new \PHPWorkflow\Workflow('AddSongToPlaylist')) 74 | ->validate(new CurrentUserIsAllowedToEditPlaylistValidator()) 75 | ->validate(new PlaylistAlreadyContainsSongValidator()) 76 | ->before(new AcceptOpenSuggestionForSong()) 77 | ->process(new AddSongToPlaylist()) 78 | ->onSuccess(new NotifySubscribers()) 79 | ->onSuccess(new AddPlaylistLogEntry()) 80 | ->onSuccess(new UpdateLastAddedToPlaylists()) 81 | ->onError(new RecoverLog()) 82 | ->executeWorkflow(); 83 | ``` 84 | 85 | This workflow may create an execution log which looks like the following (more examples coming up later): 86 | 87 | ``` 88 | Process log for workflow 'AddSongToPlaylist': 89 | Validation: 90 | - Check if the playlist is editable: ok 91 | - Check if the playlist already contains the requested song: ok 92 | Before: 93 | - Accept open suggestions for songs which shall be added: skipped (No open suggestions for playlist) 94 | Process: 95 | - Add the songs to the playlist: ok 96 | - Appended song at the end of the playlist 97 | - New playlist length: 2 98 | On Success: 99 | - Notify playlist subscribers about added song: ok 100 | - Notified 5 users 101 | - Persist changes in the playlist log: ok 102 | - Update the users list of last contributed playlists: ok 103 | 104 | Summary: 105 | - Workflow execution: ok 106 | - Execution time: 45.27205ms 107 | ``` 108 | 109 | Now let's check what exactly happens. 110 | Each step of your workflow is represented by an own class which implements the step. 111 | Steps may be used in multiple workflows (for example the **CurrentUserIsAllowedToEditPlaylistValidator** can be used in every workflow which modifies playlists). 112 | Each of these classes representing a single step must implement the **\PHPWorkflow\Step\WorkflowStep** interface. 113 | Until you call the **executeWorkflow** method no step will be executed. 114 | 115 | By calling the **executeWorkflow** method the workflow engine is triggered to start the execution with the first used stage. 116 | In our example the validations will be executed first. 117 | If all validations are successfully the next stage will be executed otherwise the workflow execution will be cancelled. 118 | 119 | Let's have a more precise look at the implementation of a single step through the example of the *before* step **AcceptOpenSuggestionForSong**. 120 | Some feature background to understand what's happening in our example: our application allows users to suggest songs for playlists. 121 | If the owner of a playlist adds a song to a playlist which already exists as an open suggestion the suggestion shall be accepted instead of adding the song to the playlist and leave the suggestion untouched. 122 | Now let's face the implementation with some inline comments to describe the workflow control: 123 | 124 | ```php 125 | class AcceptOpenSuggestionForSong implements \PHPWorkflow\Step\WorkflowStep { 126 | /** 127 | * Each step must provide a description. The description will be used in the debug 128 | * log of the workflow to get a readable representation of an executed workflow 129 | */ 130 | public function getDescription(): string 131 | { 132 | return 'Accept open suggestions for songs which shall be added to a playlist'; 133 | } 134 | 135 | /** 136 | * Each step will get access to two objects to interact with the workflow. 137 | * First the WorkflowControl object $control which provides methods to skip 138 | * steps, mark tests as failed, add debug information etc. 139 | * Second the WorkflowContainer object $container which allows us to get access 140 | * to various workflow related objects. 141 | */ 142 | public function run( 143 | \PHPWorkflow\WorkflowControl $control, 144 | \PHPWorkflow\State\WorkflowContainer $container 145 | ) { 146 | $openSuggestions = (new SuggestionRepository()) 147 | ->getOpenSuggestionsByPlaylistId($container->get('playlist')->getId()); 148 | 149 | // If we detect a condition which makes a further execution of the step 150 | // unnecessary we can simply skip the further execution. 151 | // By providing a meaningful reason our workflow debug log will be helpful. 152 | if (empty($openSuggestions)) { 153 | $control->skipStep('No open suggestions for playlist'); 154 | } 155 | 156 | foreach ($openSuggestions as $suggestion) { 157 | if ($suggestion->getSongId() === $container->get('song')->getId()) { 158 | if ((new SuggestionService())->acceptSuggestion($suggestion)) { 159 | // If we detect a condition where the further workflow execution is 160 | // unnecessary we can skip the further execution. 161 | // In this example the open suggestion was accepted successfully so 162 | // the song must not be added to the playlist via the workflow. 163 | $control->skipWorkflow('Accepted open suggestion'); 164 | } 165 | 166 | // We can add warnings to the debug log. Another option in this case could 167 | // be to call $control->failWorkflow() if we want the workflow to fail in 168 | // an error case. 169 | // In our example, if the suggestion can't be accepted, we want to add the 170 | // song to the playlist via the workflow. 171 | $control->warning("Failed to accept open suggestion {$suggestion->getId()}"); 172 | } 173 | } 174 | 175 | // for completing the debug log we mark this step as skipped if no action has been 176 | // performed. If we don't mark the step as skipped and no action has been performed 177 | // the step will occur as 'ok' in the debug log - depends on your preferences :) 178 | $control->skipStep('No matching open suggestion'); 179 | } 180 | } 181 | ``` 182 | 183 | ## Workflow container 184 | 185 | Now let's have a more detailed look at the **WorkflowContainer** which helps us, to share data and objects between our workflow steps. 186 | The relevant objects for our example workflow is the **User** who wants to add the song, the **Song** object of the song to add and the **Playlist** object. 187 | Before we execute our workflow we can set up a **WorkflowContainer** which contains all relevant objects: 188 | 189 | ```php 190 | $workflowContainer = (new \PHPWorkflow\State\WorkflowContainer()) 191 | ->set('user', Session::getUser()) 192 | ->set('song', (new SongRepository())->getSongById($request->get('songId'))) 193 | ->set('playlist', (new PlaylistRepository())->getPlaylistById($request->get('playlistId'))); 194 | ``` 195 | 196 | The workflow container provides the following interface: 197 | 198 | ```php 199 | // returns an item or null if the key doesn't exist 200 | public function get(string $key) 201 | // set or update a value 202 | public function set(string $key, $value): self 203 | // remove an entry 204 | public function unset(string $key): self 205 | // check if a key exists 206 | public function has(string $key): bool 207 | ``` 208 | 209 | Each workflow step may define requirements, which entries must be present in the workflow container before the step is executed. 210 | For more details have a look at [Required container values](#Required-container-values). 211 | 212 | Alternatively to set and get the values from the **WorkflowContainer** via string keys you can extend the **WorkflowContainer** and add typed properties/functions to handle values in a type-safe manner: 213 | 214 | ```php 215 | class AddSongToPlaylistWorkflowContainer extends \PHPWorkflow\State\WorkflowContainer { 216 | public function __construct( 217 | public User $user, 218 | public Song $song, 219 | public Playlist $playlist, 220 | ) {} 221 | } 222 | 223 | $workflowContainer = new AddSongToPlaylistWorkflowContainer( 224 | Session::getUser(), 225 | (new SongRepository())->getSongById($request->get('songId')), 226 | (new PlaylistRepository())->getPlaylistById($request->get('playlistId')), 227 | ); 228 | ``` 229 | 230 | When we execute the workflow via **executeWorkflow** we can inject the **WorkflowContainer**. 231 | 232 | ```php 233 | $workflowResult = (new \PHPWorkflow\Workflow('AddSongToPlaylist')) 234 | // ... 235 | ->executeWorkflow($workflowContainer); 236 | ``` 237 | 238 | Another possibility would be to define a step in the **Prepare** stage (e.g. **PopulateAddSongToPlaylistContainer**) which populates the automatically injected empty **WorkflowContainer** object. 239 | 240 | ## Stages 241 | 242 | The following predefined stages are available when defining a workflow: 243 | 244 | * Prepare 245 | * Validate 246 | * Before 247 | * Process 248 | * OnSuccess 249 | * OnError 250 | * After 251 | 252 | Each stage has a defined set of stages which may be called afterwards (e.g. you may skip the **Before** stage). 253 | When setting up a workflow your IDE will support you by suggesting only possible next steps via autocompletion. 254 | Each workflow must contain at least one step in the **Process** stage. 255 | 256 | Any step added to the workflow may throw an exception. Each exception will be caught and is handled like a failed step. 257 | If a step in the stages **Prepare**, **Validate** (see details for the stage) or **Before** fails, the workflow is failed and will not be executed further. 258 | 259 | Any step may skip or fail the workflow via the **WorkflowControl**. 260 | If the **Process** stage has been executed and any later step tries to fail or skip the whole workflow it's handled as a failed/skipped step. 261 | 262 | Now let's have a look at some stage-specific details. 263 | 264 | ### Prepare 265 | 266 | This stage allows you to add steps which must be executed before any validation or process execution is triggered. 267 | Steps may contain data loading, gaining workflow relevant semaphores, etc. 268 | 269 | ### Validate 270 | 271 | This stage allows you to execute validations. 272 | There are two types of validations: hard validations and soft validations. 273 | All hard validations of the workflow will be executed before the soft validations. 274 | If a hard validation fails the workflow will be stopped immediately (e.g. access right violations). 275 | All soft validations of the workflow will be executed independently of their result. 276 | All failing soft validations will be collected in a **\PHPWorkflow\Exception\WorkflowValidationException** which is thrown at the end of the validation stage if any of the soft validations failed. 277 | 278 | When you attach a validation to your workflow the second parameter of the **validate** method defines if the validation is a soft or a hard validation: 279 | 280 | ```php 281 | 282 | $workflowResult = (new \PHPWorkflow\Workflow('AddSongToPlaylist')) 283 | // hard validator: if the user isn't allowed to edit the playlist 284 | // the workflow execution will be cancelled immediately 285 | ->validate(new CurrentUserIsAllowedToEditPlaylistValidator(), true) 286 | // soft validators: all validators will be executed 287 | ->validate(new PlaylistAlreadyContainsSongValidator()) 288 | ->validate(new SongGenreMatchesPlaylistGenreValidator()) 289 | ->validate(new PlaylistContainsNoSongsFromInterpret()) 290 | // ... 291 | ``` 292 | 293 | In the provided example any of the soft validators may fail (e.g. the **SongGenreMatchesPlaylistGenreValidator** checks if the genre of the song matches the playlist, the **PlaylistContainsNoSongsFromInterpret** may check for duplicated interprets). 294 | The thrown **WorkflowValidationException** allows us to determine all violations and set up a corresponding error message. 295 | If all validators pass the next stage will be executed. 296 | 297 | ### Before 298 | 299 | This stage allows you to perform preparatory steps with the knowledge that the workflow execution is valid. 300 | This steps may contain the allocation of resources, filtering the data to process etc. 301 | 302 | ### Process 303 | 304 | This stage contains your main logic of the workflow. If any of the steps fails no further steps of the process stage will be executed. 305 | 306 | ### OnSuccess 307 | 308 | This stage allows you to define steps which shall be executed if all steps of the **Process** stage are executed successfully. 309 | For example logging, notifications, sending emails, etc. 310 | 311 | All steps of the stage will be executed, even if some steps fail. All failing steps will be reported as warnings. 312 | 313 | ### OnError 314 | 315 | This stage allows you to define steps which shall be executed if any step of the **Process** stage fails. 316 | For example logging, setting up recovery data, etc. 317 | 318 | All steps of the stage will be executed, even if some steps fail. All failing steps will be reported as warnings. 319 | 320 | ### After 321 | 322 | This stage allows you to perform cleanup steps after all other stages have been executed. The steps will be executed regardless of the successful execution of the **Process** stage. 323 | 324 | All steps of the stage will be executed, even if some steps fail. All failing steps will be reported as warnings. 325 | 326 | ## Workflow control 327 | 328 | The **WorkflowControl** object which is injected into each step provides the following interface to interact with the workflow: 329 | 330 | ```php 331 | // Mark the current step as skipped. 332 | // Use this if you detect, that the step execution is not necessary 333 | // (e.g. disabled by config, no entity to process, ...) 334 | public function skipStep(string $reason): void; 335 | 336 | // Mark the current step as failed. A failed step before and during the processing of 337 | // a workflow leads to a failed workflow. 338 | public function failStep(string $reason): void; 339 | 340 | // Mark the workflow as failed. If the workflow is failed after the process stage has 341 | // been executed it's handled like a failed step. 342 | public function failWorkflow(string $reason): void; 343 | 344 | // Skip the further workflow execution (e.g. if you detect it's not necessary to process 345 | // the workflow). If the workflow is skipped after the process stage has been executed 346 | // it's handled like a skipped step. 347 | public function skipWorkflow(string $reason): void; 348 | 349 | // Useful when using loops to cancel the current iteration (all upcoming steps). 350 | // If used outside a loop, it behaves like skipStep. 351 | public function continue(string $reason): void; 352 | 353 | // Useful when using loops to break the loop (all upcoming steps and iterations). 354 | // If used outside a loop, it behaves like skipStep. 355 | public function break(string $reason): void; 356 | 357 | // Attach any additional debug info to your current step. 358 | // The infos will be shown in the workflow debug log. 359 | public function attachStepInfo(string $info): void 360 | 361 | // Add a warning to the workflow. 362 | // All warnings will be collected and shown in the workflow debug log. 363 | // You can provide an additional exception which caused the warning. 364 | // If you provide the exception, exception details will be added to the debug log. 365 | public function warning(string $message, ?Exception $exception = null): void; 366 | ``` 367 | 368 | ## Nested workflows 369 | 370 | If some of your steps become more complex you may want to have a look into the `NestedWorkflow` wrapper which allows you to use a second workflow as a step of your workflow: 371 | 372 | ```php 373 | $parentWorkflowContainer = (new \PHPWorkflow\State\WorkflowContainer())->set('parent-data', 'Hello'); 374 | $nestedWorkflowContainer = (new \PHPWorkflow\State\WorkflowContainer())->set('nested-data', 'World'); 375 | 376 | $workflowResult = (new \PHPWorkflow\Workflow('AddSongToPlaylist')) 377 | ->validate(new CurrentUserIsAllowedToEditPlaylistValidator()) 378 | ->before(new \PHPWorkflow\Step\NestedWorkflow( 379 | (new \PHPWorkflow\Workflow('AcceptOpenSuggestions')) 380 | ->validate(new PlaylistAcceptsSuggestionsValidator()) 381 | ->before(new LoadOpenSuggestions()) 382 | ->process(new AcceptOpenSuggestions()) 383 | ->onSuccess(new NotifySuggestor()), 384 | $nestedWorkflowContainer, 385 | )) 386 | ->process(new AddSongToPlaylist()) 387 | ->onSuccess(new NotifySubscribers()) 388 | ->executeWorkflow($parentWorkflowContainer); 389 | ``` 390 | 391 | Each nested workflow must be executable (contain at least one **Process** step). 392 | 393 | The debug log of your nested workflow will be embedded in the debug log of your main workflow. 394 | 395 | As you can see in the example you can inject a dedicated **WorkflowContainer** into the nested workflow. 396 | The nested workflow will gain access to a merged **WorkflowContainer** which provides all data and methods of your main workflow container and your nested container. 397 | If you add additional data to the merged container the data will be present in your main workflow container after the nested workflow execution has been completed. 398 | For example your implementations of the steps used in the nested workflow will have access to the keys `nested-data` and `parent-data`. 399 | 400 | ## Loops 401 | 402 | If you handle multiple entities in your workflows at once you may need loops. 403 | An approach would be to set up a single step which contains the loop and all logic which is required to be executed in a loop. 404 | But if there are multiple steps required to be executed in the loop you may want to split the step into various steps. 405 | By using the `Loop` class you can execute multiple steps in a loop. 406 | For example let's assume our `AddSongToPlaylist` becomes a `AddSongsToPlaylist` workflow which can add multiple songs at once: 407 | 408 | ```php 409 | $workflowResult = (new \PHPWorkflow\Workflow('AddSongToPlaylist')) 410 | ->validate(new CurrentUserIsAllowedToEditPlaylistValidator()) 411 | ->process( 412 | (new \PHPWorkflow\Step\Loop(new SongLoop())) 413 | ->addStep(new AddSongToPlaylist()) 414 | ->addStep(new ClearSongCache()) 415 | ) 416 | ->onSuccess(new NotifySubscribers()) 417 | ->executeWorkflow($workflowContainer); 418 | ``` 419 | 420 | Our process step now implements a loop controlled by the `SongLoop` class. 421 | The loop contains our two steps `AddSongToPlaylist` and `ClearSongCache`. 422 | The implementation of the `SongLoop` class must implement the `PHPWorkflow\Step\LoopControl` interface. 423 | Let's have a look at an example implementation: 424 | 425 | ```php 426 | class SongLoop implements \PHPWorkflow\Step\LoopControl { 427 | /** 428 | * As well as each step also each loop must provide a description. 429 | */ 430 | public function getDescription(): string 431 | { 432 | return 'Loop over all provided songs'; 433 | } 434 | 435 | /** 436 | * This method will be called before each loop run. 437 | * $iteration will contain the current iteration (0 on first run etc) 438 | * You have access to the WorkflowControl and the WorkflowContainer. 439 | * If the method returns true the next iteration will be executed. 440 | * Otherwise the loop is completed. 441 | */ 442 | public function executeNextIteration( 443 | int $iteration, 444 | \PHPWorkflow\WorkflowControl $control, 445 | \PHPWorkflow\State\WorkflowContainer $container 446 | ): bool { 447 | // all songs handled - end the loop 448 | if ($iteration === count($container->get('songs'))) { 449 | return false; 450 | } 451 | 452 | // add the current song to the container so the steps 453 | // of the loop can access the entry 454 | $container->set('currentSong', $container->get('songs')[$iteration]); 455 | 456 | return true; 457 | } 458 | } 459 | ``` 460 | 461 | A loop step may contain a nested workflow if you need more complex steps. 462 | 463 | To control the flow of the loop from the steps you can use the `continue` and `break` methods on the `WorkflowControl` object. 464 | 465 | By default, a loop is stopped if a step fails. 466 | You can set the second parameter of the `Loop` class (`$continueOnError`) to true to continue the execution with the next iteration. 467 | If you enable this option a failed step will not result in a failed workflow. 468 | Instead, a warning will be added to the process log. 469 | Calls to `failWorkflow` and `skipWorkflow` will always cancel the loop (and consequently the workflow) independent of the option. 470 | 471 | ## Step dependencies 472 | 473 | Each step implementation may apply dependencies to the step. 474 | By defining dependencies you can set up validation rules which are checked before your step is executed (for example: which data must be provided in the workflow container). 475 | If any of the dependencies is not fulfilled, the step will not be executed and is handled as a failed step. 476 | 477 | Note: as this feature uses [Attributes](https://www.php.net/manual/de/language.attributes.overview.php), it is only available if you use PHP >= 8.0. 478 | 479 | ### Required container values 480 | 481 | With the `\PHPWorkflow\Step\Dependency\Required` attribute you can define keys which must be present in the provided workflow container. 482 | The keys consequently must be provided in the initial workflow or be populated by a previous step. 483 | Additionally to the key you can also provide the type of the value (eg. `string`). 484 | 485 | To define the dependency you simply annotate the provided workflow container parameter: 486 | 487 | ```php 488 | public function run( 489 | \PHPWorkflow\WorkflowControl $control, 490 | // The key customerId must contain a string 491 | #[\PHPWorkflow\Step\Dependency\Required('customerId', 'string')] 492 | // The customerAge must contain an integer. But also null is accepted. 493 | // Each type definition can be prefixed with a ? to accept null. 494 | #[\PHPWorkflow\Step\Dependency\Required('customerAge', '?int')] 495 | // Objects can also be type hinted 496 | #[\PHPWorkflow\Step\Dependency\Required('created', \DateTime::class)] 497 | \PHPWorkflow\State\WorkflowContainer $container, 498 | ) { 499 | // Implementation which can rely on the defined keys to be present in the container. 500 | } 501 | ``` 502 | 503 | The following types are supported: `string`, `bool`, `int`, `float`, `object`, `array`, `iterable`, `scalar` as well as object type hints by providing the corresponding FQCN. 504 | 505 | ## Error handling, logging and debugging 506 | 507 | The **executeWorkflow** method returns an **WorkflowResult** object which provides the following methods to determine the result of the workflow: 508 | 509 | ```php 510 | // check if the workflow execution was successful 511 | public function success(): bool; 512 | // check if warnings were emitted during the workflow execution 513 | public function hasWarnings(): bool; 514 | // get a list of warnings, grouped by stage 515 | public function getWarnings(): array; 516 | // get the exception which caused the workflow to fail 517 | public function getException(): ?Exception; 518 | // get the debug execution log of the workflow 519 | public function debug(?OutputFormat $formatter = null); 520 | // access the container which was used for the workflow 521 | public function getContainer(): WorkflowContainer; 522 | // get the last executed step 523 | // (e.g. useful to determine which step caused a workflow to fail) 524 | public function getLastStep(): WorkflowStep; 525 | ``` 526 | 527 | As stated above workflows with failing steps before the **Process** stage will be aborted, otherwise the **Process** stage and all downstream stages will be executed. 528 | 529 | By default, the execution of a workflow throws an exception if an error occurs. 530 | The thrown exception will be a **\PHPWorkflow\Exception\WorkflowException** which allows you to access the **WorkflowResult** object via the **getWorkflowResult** method. 531 | 532 | The **debug** method provides an execution log including all processed steps with their status, attached data as well as a list of all warnings and performance data. 533 | 534 | Some example outputs for our example workflow may look like the following. 535 | 536 | #### Successful execution 537 | 538 | ``` 539 | Process log for workflow 'AddSongToPlaylist': 540 | Validation: 541 | - Check if the playlist is editable: ok 542 | - Check if the playlist already contains the requested song: ok 543 | Before: 544 | - Accept open suggestions for songs which shall be added: skipped (No open suggestions for playlist) 545 | Process: 546 | - Add the songs to the playlist: ok 547 | - Appended song at the end of the playlist 548 | - New playlist length: 2 549 | On Success: 550 | - Notify playlist subscribers about added song: ok 551 | - Notified 5 users 552 | - Persist changes in the playlist log: ok 553 | - Update the users list of last contributed playlists: ok 554 | 555 | Summary: 556 | - Workflow execution: ok 557 | - Execution time: 45.27205ms 558 | ``` 559 | 560 | Note the additional data added to the debug log for the **Process** stage and the **NotifySubscribers** step via the **attachStepInfo** method of the **WorkflowControl**. 561 | 562 | #### Failed workflow 563 | 564 | ``` 565 | Process log for workflow 'AddSongToPlaylist': 566 | Validation: 567 | - Check if the playlist is editable: failed (playlist locked) 568 | 569 | Summary: 570 | - Workflow execution: failed 571 | - Execution time: 6.28195ms 572 | ``` 573 | 574 | In this example the **CurrentUserIsAllowedToEditPlaylistValidator** step threw an exception with the message `playlist locked`. 575 | 576 | #### Workflow skipped 577 | 578 | ``` 579 | Process log for workflow 'AddSongToPlaylist': 580 | Validation: 581 | - Check if the playlist is editable: ok 582 | - Check if the playlist already contains the requested song: ok 583 | Before: 584 | - Accept open suggestions for songs which shall be added: ok (Accepted open suggestion) 585 | 586 | Summary: 587 | - Workflow execution: skipped (Accepted open suggestion) 588 | - Execution time: 89.56986ms 589 | ``` 590 | 591 | In this example the **AcceptOpenSuggestionForSong** step found a matching open suggestion and successfully accepted the suggestion. 592 | Consequently, the further workflow execution is skipped. 593 | 594 | ### Custom output formatter 595 | 596 | The output of the `debug` method can be controlled via an implementation of the `OutputFormat` interface. 597 | By default a string representation of the execution will be returned (just like the example outputs). 598 | 599 | Currently the following additional formatters are implemented: 600 | 601 | | Formatter | Description | 602 | | --------------- | ------------- | 603 | | `StringLog` | The default formatter. Creates a string representation.
Example:
`$result->debug();` | 604 | | `WorkflowGraph` | Creates a SVG file containing a graph which represents the workflow execution. The generated image will be stored in the provided target directory. Requires `dot` executable.
Example:
`$result->debug(new WorkflowGraph('/var/log/workflow/graph'));` | 605 | | `GraphViz` | Returns a string containing [GraphViz](https://graphviz.org/) code for a graph representing the workflow execution.
Example:
`$result->debug(new GraphViz());`| 606 | 607 | ## Tests 608 | 609 | The library is tested via [PHPUnit](https://phpunit.de/). 610 | 611 | After installing the dependencies of the library via `composer update` you can execute the tests with `./vendor/bin/phpunit` (Linux) or `vendor\bin\phpunit.bat` (Windows). 612 | The test names are optimized for the usage of the `--testdox` output. 613 | 614 | If you want to test workflows you may include the `PHPWorkflow\Tests\WorkflowTestTrait` which adds methods to simplify asserting workflow results. 615 | The following methods are added to your test classes: 616 | 617 | ```php 618 | // assert the debug output of the workflow. See library tests for example usages 619 | protected function assertDebugLog(string $expected, WorkflowResult $result): void 620 | // provide a step which you expect to fail the workflow. 621 | // example: $this->expectFailAtStep(MyFailingStep::class, $workflowResult); 622 | protected function expectFailAtStep(string $step, WorkflowResult $result): void 623 | // provide a step which you expect to skip the workflow. 624 | // example: $this->expectSkipAtStep(MySkippingStep::class, $workflowResult); 625 | protected function expectSkipAtStep(string $step, WorkflowResult $result): void 626 | ``` 627 | 628 | ## Workshop 629 | 630 | Maybe you want to try out the library and lack a simple idea to solve with the library. 631 | Therefore, here's a small workshop which covers most of the library's features. 632 | Implement the task given below (which, to be fair, can surely be implemented easier without the library but the library is designed to support large workflows with a lot of business logic) to have an idea how coding with the library works. 633 | 634 | Your data input for this task is a simple array with a list of persons in the following format: 635 | 636 | ```php 637 | [ 638 | 'firstname' => string, 639 | 'lastname' => string, 640 | 'age' => int, 641 | ] 642 | ``` 643 | 644 | The workflow shall implement the following steps: 645 | 646 | 1. Check if the list is empty. In this case finish the workflow directly 647 | 2. Check if the list contains persons with an age below 18 years. In this case the workflow should fail 648 | 3. Make sure each firstname and lastname is populated. If any empty fields are detected, the workflow should fail 649 | 4. Before processing the list normalize the firstnames and lastnames (`ucfirst` and `trim`) 650 | - Make sure the workflow log contains the amount of changed data sets 651 | 5. Process the list. The processing itself splits up into the following steps: 652 | 1. Make sure a directory of your choice contains a CSV file for each age from the input data 653 | - If the file doesn't exist, create a new file 654 | - The workflow log must contain information about new files 655 | 2. Add all persons from your input data to the corresponding files 656 | - The workflow log must display the amount of persons added to each file 657 | 6. If all persons were persisted successfully create a ZIP backup of all files 658 | 7. If an error occurred rollback to the last existing ZIP backup 659 | 660 | If you have finished implementing the workflow, pick a step of your choice and implement a unit test for the step. 661 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wol-soft/php-workflow", 3 | "homepage": "https://github.com/wol-soft/php-workflow", 4 | "description": "Stick together workflows", 5 | "type": "library", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Enno Woortmann", 10 | "email": "enno.woortmann@web.de" 11 | } 12 | ], 13 | "require": { 14 | "php": ">=7.4" 15 | }, 16 | "require-dev": { 17 | "phpunit/phpunit": "^8.5 || ^9.5" 18 | }, 19 | "autoload": { 20 | "psr-4": { 21 | "PHPWorkflow\\": "src" 22 | } 23 | }, 24 | "autoload-dev": { 25 | "psr-4": { 26 | "PHPWorkflow\\Tests\\": "tests" 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /phpmd.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Custom ruleset for code standards 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 15 | 16 | 17 | src 18 | 19 | 20 | src/State/ExecutionLog/OutputFormat 21 | 22 | 23 | 24 | tests 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/Exception/WorkflowControl/BreakException.php: -------------------------------------------------------------------------------- 1 | workflowResult = $workflowResult; 18 | } 19 | 20 | public function getWorkflowResult(): WorkflowResult 21 | { 22 | return $this->workflowResult; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Exception/WorkflowStepDependencyNotFulfilledException.php: -------------------------------------------------------------------------------- 1 | validationErrors = $validationErrors; 19 | } 20 | 21 | /** 22 | * @return Exception[] 23 | */ 24 | public function getValidationErrors(): array 25 | { 26 | return $this->validationErrors; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/ExecutableWorkflow.php: -------------------------------------------------------------------------------- 1 | $control->attachStepInfo( 16 | "Step execution time: " . number_format(1000 * (microtime(true) - $start), 5) . 'ms', 17 | ); 18 | 19 | try { 20 | $result = $next(); 21 | $profile(); 22 | } catch (Exception $exception) { 23 | $profile(); 24 | throw $exception; 25 | } 26 | 27 | return $result; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Middleware/WorkflowStepDependencyCheck.php: -------------------------------------------------------------------------------- 1 | getParameters()[1] ?? null; 29 | 30 | if ($containerParameter) { 31 | foreach ($containerParameter->getAttributes( 32 | StepDependencyInterface::class, 33 | ReflectionAttribute::IS_INSTANCEOF, 34 | ) as $dependencyAttribute 35 | ) { 36 | /** @var StepDependencyInterface $dependency */ 37 | $dependency = $dependencyAttribute->newInstance(); 38 | $dependency->check($container); 39 | } 40 | } 41 | 42 | return $next(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Stage/After.php: -------------------------------------------------------------------------------- 1 | addStep($step); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Stage/Before.php: -------------------------------------------------------------------------------- 1 | addStep($step); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Stage/MultiStepStage.php: -------------------------------------------------------------------------------- 1 | steps[] = $step; 20 | return $this; 21 | } 22 | 23 | protected function runStage(WorkflowState $workflowState): ?Stage 24 | { 25 | $workflowState->setStage(static::STAGE); 26 | 27 | foreach ($this->steps as $step) { 28 | $workflowState->setStep($step); 29 | $this->wrapStepExecution($step, $workflowState); 30 | } 31 | 32 | return $this->nextStage; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Stage/Next/AllowNextAfter.php: -------------------------------------------------------------------------------- 1 | nextStage = (new After($this->workflow))->after($step); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Stage/Next/AllowNextBefore.php: -------------------------------------------------------------------------------- 1 | nextStage = (new Before($this->workflow))->before($step); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Stage/Next/AllowNextExecuteWorkflow.php: -------------------------------------------------------------------------------- 1 | set( 27 | '__internalExecutionConfiguration', 28 | [ 29 | 'throwOnFailure' => $throwOnFailure, 30 | ], 31 | ); 32 | 33 | $workflowState = new WorkflowState($workflowContainer); 34 | 35 | try { 36 | $workflowState->getExecutionLog()->startExecution(); 37 | 38 | $this->workflow->runStage($workflowState); 39 | 40 | $workflowState->getExecutionLog()->stopExecution(); 41 | $workflowState->setStage(WorkflowState::STAGE_SUMMARY); 42 | $workflowState->addExecutionLog(new Summary('Workflow execution')); 43 | } catch (Exception $exception) { 44 | $workflowState->getExecutionLog()->stopExecution(); 45 | $workflowState->setStage(WorkflowState::STAGE_SUMMARY); 46 | $workflowState->addExecutionLog( 47 | new Summary('Workflow execution'), 48 | $exception instanceof SkipWorkflowException ? ExecutionLog::STATE_SKIPPED : ExecutionLog::STATE_FAILED, 49 | $exception->getMessage(), 50 | ); 51 | 52 | if ($exception instanceof SkipWorkflowException) { 53 | return $workflowState->close(true); 54 | } 55 | 56 | $result = $workflowState->close(false, $exception); 57 | 58 | if ($throwOnFailure) { 59 | throw new WorkflowException( 60 | $result, 61 | "Workflow '{$workflowState->getWorkflowName()}' failed", 62 | $exception, 63 | ); 64 | } 65 | 66 | return $result; 67 | } 68 | 69 | return $workflowState->close(true); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Stage/Next/AllowNextOnError.php: -------------------------------------------------------------------------------- 1 | nextStage = (new OnError($this->workflow))->onError($step); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Stage/Next/AllowNextOnSuccess.php: -------------------------------------------------------------------------------- 1 | nextStage = (new OnSuccess($this->workflow))->onSuccess($step); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Stage/Next/AllowNextPrepare.php: -------------------------------------------------------------------------------- 1 | nextStage = (new Prepare($this->workflow))->prepare($step); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Stage/Next/AllowNextProcess.php: -------------------------------------------------------------------------------- 1 | nextStage = (new Process($this->workflow))->process($step); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Stage/Next/AllowNextValidator.php: -------------------------------------------------------------------------------- 1 | nextStage = (new Validate($this->workflow))->validate($step, $hardValidator); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Stage/OnError.php: -------------------------------------------------------------------------------- 1 | addStep($step); 26 | } 27 | 28 | protected function runStage(WorkflowState $workflowState): ?Stage 29 | { 30 | // don't execute onError steps if the workflow was successful 31 | if (!$workflowState->getProcessException()) { 32 | return $this->nextStage; 33 | } 34 | 35 | return parent::runStage($workflowState); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Stage/OnSuccess.php: -------------------------------------------------------------------------------- 1 | addStep($step); 26 | } 27 | 28 | protected function runStage(WorkflowState $workflowState): ?Stage 29 | { 30 | // don't execute onSuccess steps if the workflow failed 31 | if ($workflowState->getProcessException()) { 32 | return $this->nextStage; 33 | } 34 | 35 | return parent::runStage($workflowState); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Stage/Prepare.php: -------------------------------------------------------------------------------- 1 | addStep($step); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Stage/Process.php: -------------------------------------------------------------------------------- 1 | addStep($step); 29 | } 30 | 31 | protected function runStage(WorkflowState $workflowState): ?Stage 32 | { 33 | try { 34 | parent::runStage($workflowState); 35 | } catch (Exception $exception) { 36 | $workflowState->setProcessException($exception); 37 | } 38 | 39 | return $this->nextStage; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Stage/Stage.php: -------------------------------------------------------------------------------- 1 | workflow = $workflow; 21 | } 22 | 23 | abstract protected function runStage(WorkflowState $workflowState): ?Stage; 24 | } 25 | -------------------------------------------------------------------------------- /src/Stage/Validate.php: -------------------------------------------------------------------------------- 1 | validators[] = new Validator($step, $hardValidator); 29 | return $this; 30 | } 31 | 32 | protected function runStage(WorkflowState $workflowState): ?Stage 33 | { 34 | $workflowState->setStage(WorkflowState::STAGE_VALIDATE); 35 | 36 | // make sure hard validators are executed first 37 | usort($this->validators, function (Validator $validator, Validator $comparedValidator): int { 38 | if ($validator->isHardValidator() xor $comparedValidator->isHardValidator()) { 39 | return $validator->isHardValidator() && !$comparedValidator->isHardValidator() ? -1 : 1; 40 | } 41 | return 0; 42 | }); 43 | 44 | $validationErrors = []; 45 | foreach ($this->validators as $validator) { 46 | $workflowState->setStep($validator->getStep()); 47 | 48 | if ($validator->isHardValidator()) { 49 | $this->wrapStepExecution($validator->getStep(), $workflowState); 50 | } else { 51 | try { 52 | $this->wrapStepExecution($validator->getStep(), $workflowState); 53 | } catch (SkipWorkflowException $exception) { 54 | throw $exception; 55 | } catch (Exception $exception) { 56 | $validationErrors[] = $exception; 57 | } 58 | } 59 | } 60 | 61 | if ($validationErrors) { 62 | throw new WorkflowValidationException($validationErrors); 63 | } 64 | 65 | return $this->nextStage; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/State/ExecutionLog/Describable.php: -------------------------------------------------------------------------------- 1 | workflowState = $workflowState; 32 | } 33 | 34 | public function addStep(int $stage, Describable $step, string $state, ?string $reason): void { 35 | $this->stages[$stage][] = new Step($step, $state, $reason, $this->stepInfo, $this->warningsDuringStep); 36 | $this->stepInfo = []; 37 | $this->warningsDuringStep = 0; 38 | } 39 | 40 | public function debug(OutputFormat $formatter) 41 | { 42 | return $formatter->format($this->workflowState->getWorkflowName(), $this->stages); 43 | } 44 | 45 | public function attachStepInfo(string $info, array $context = []): void 46 | { 47 | $this->stepInfo[] = new StepInfo($info, $context); 48 | } 49 | 50 | public function addWarning(string $message, bool $workflowReportWarning = false): void 51 | { 52 | $this->warnings[$this->workflowState->getStage()][] = $message; 53 | 54 | if (!$workflowReportWarning) { 55 | $this->warningsDuringStep++; 56 | } 57 | } 58 | 59 | public function startExecution(): void 60 | { 61 | $this->startAt = microtime(true); 62 | } 63 | 64 | public function stopExecution(): void 65 | { 66 | $this->attachStepInfo('Execution time: ' . number_format(1000 * (microtime(true) - $this->startAt), 5) . 'ms'); 67 | 68 | if ($this->warnings) { 69 | $warnings = sprintf( 70 | 'Got %s warning%s during the execution:', 71 | $amount = count($this->warnings, COUNT_RECURSIVE) - count($this->warnings), 72 | $amount > 1 ? 's' : '', 73 | ); 74 | 75 | foreach ($this->warnings as $stage => $stageWarnings) { 76 | $warnings .= implode( 77 | '', 78 | array_map( 79 | fn (string $warning): string => 80 | sprintf(PHP_EOL . ' %s: %s', self::mapStage($stage), $warning), 81 | $stageWarnings, 82 | ), 83 | ); 84 | } 85 | 86 | $this->attachStepInfo($warnings); 87 | } 88 | } 89 | 90 | public static function mapStage(int $stage): string 91 | { 92 | switch ($stage) { 93 | case WorkflowState::STAGE_PREPARE: return 'Prepare'; 94 | case WorkflowState::STAGE_VALIDATE: return 'Validate'; 95 | case WorkflowState::STAGE_BEFORE: return 'Before'; 96 | case WorkflowState::STAGE_PROCESS: return 'Process'; 97 | case WorkflowState::STAGE_ON_ERROR: return 'On Error'; 98 | case WorkflowState::STAGE_ON_SUCCESS: return 'On Success'; 99 | case WorkflowState::STAGE_AFTER: return 'After'; 100 | case WorkflowState::STAGE_SUMMARY: return 'Summary'; 101 | } 102 | } 103 | 104 | public function getWarnings(): array 105 | { 106 | return $this->warnings; 107 | } 108 | 109 | public function getLastStep(): WorkflowStep 110 | { 111 | return $this->workflowState->getCurrentStep(); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/State/ExecutionLog/OutputFormat/GraphViz.php: -------------------------------------------------------------------------------- 1 | renderWorkflowGraph($workflowName, $steps); 26 | 27 | for ($i = 0; $i < self::$stepIndex - 1; $i++) { 28 | if (isset(self::$loopLinks[$i + 1])) { 29 | continue; 30 | } 31 | 32 | $dotScript .= sprintf(" %s -> %s\n", $i, $i + 1); 33 | } 34 | 35 | foreach (self::$loopLinks as $loopElement => $loopRoot) { 36 | $dotScript .= sprintf(" %s -> %s\n", $loopRoot, $loopElement); 37 | } 38 | 39 | $dotScript .= '}'; 40 | 41 | return $dotScript; 42 | } 43 | 44 | private function renderWorkflowGraph(string $workflowName, array $steps): string 45 | { 46 | $dotScript = sprintf(" %s [label=\"$workflowName\"]\n", self::$stepIndex++); 47 | foreach ($steps as $stage => $stageSteps) { 48 | $dotScript .= sprintf( 49 | " subgraph cluster_%s {\n label = \"%s\"\n", 50 | self::$clusterIndex++, 51 | ExecutionLog::mapStage($stage) 52 | ); 53 | 54 | /** @var Step $step */ 55 | foreach ($stageSteps as $step) { 56 | foreach ($step->getStepInfo() as $info) { 57 | switch ($info->getInfo()) { 58 | case StepInfo::LOOP_START: 59 | $dotScript .= sprintf( 60 | " subgraph cluster_loop_%s {\n label = \"Loop\"\n", 61 | self::$clusterIndex++ 62 | ); 63 | 64 | self::$loopInitialElement[++self::$loopIndex] = self::$stepIndex; 65 | 66 | continue 2; 67 | case StepInfo::LOOP_ITERATION: 68 | self::$loopLinks[self::$stepIndex + 1] = self::$loopInitialElement[self::$loopIndex]; 69 | 70 | continue 2; 71 | case StepInfo::LOOP_END: 72 | $dotScript .= "\n}\n"; 73 | array_pop(self::$loopLinks); 74 | self::$loopIndex--; 75 | 76 | continue 2; 77 | case StepInfo::NESTED_WORKFLOW: 78 | /** @var WorkflowResult $nestedWorkflowResult */ 79 | $nestedWorkflowResult = $info->getContext()['result']; 80 | $nestedWorkflowGraph = $nestedWorkflowResult->debug($this); 81 | 82 | $lines = explode("\n", $nestedWorkflowGraph); 83 | array_shift($lines); 84 | array_pop($lines); 85 | 86 | $dotScript .= 87 | sprintf( 88 | " subgraph cluster_%s {\n label = \"Nested workflow\"\n", 89 | self::$clusterIndex++, 90 | ) 91 | . preg_replace('/\d+ -> \d+\s*/m', '', join("\n", $lines)) 92 | . "\n}\n"; 93 | 94 | // TODO: additional infos. Currently skipped 95 | continue 3; 96 | } 97 | } 98 | 99 | $dotScript .= sprintf( 100 | ' %s [label=%s shape="box" color="%s"]' . "\n", 101 | self::$stepIndex++, 102 | "<{$step->getDescription()} ({$step->getState()})" 103 | . ($step->getReason() ? "
{$step->getReason()}" : '') 104 | . join('', array_map( 105 | fn (StepInfo $info): string => "
{$info->getInfo()}", 106 | array_filter( 107 | $step->getStepInfo(), 108 | fn (StepInfo $info): bool => !in_array( 109 | $info->getInfo(), 110 | [ 111 | StepInfo::LOOP_START, 112 | StepInfo::LOOP_ITERATION, 113 | StepInfo::LOOP_END, 114 | StepInfo::NESTED_WORKFLOW, 115 | ], 116 | ) 117 | ), 118 | )) 119 | . ">", 120 | $this->mapColor($step), 121 | ); 122 | } 123 | $dotScript .= " }\n"; 124 | } 125 | 126 | return $dotScript; 127 | } 128 | 129 | private function mapColor(Step $step): string 130 | { 131 | if ($step->getState() === ExecutionLog::STATE_SUCCESS && $step->getWarnings()) { 132 | return 'yellow'; 133 | } 134 | 135 | return [ 136 | ExecutionLog::STATE_SUCCESS => 'green', 137 | ExecutionLog::STATE_SKIPPED => 'grey', 138 | ExecutionLog::STATE_FAILED => 'red', 139 | ][$step->getState()]; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/State/ExecutionLog/OutputFormat/OutputFormat.php: -------------------------------------------------------------------------------- 1 | $stageSteps) { 22 | $debug .= sprintf( 23 | '%s%s%s:' . PHP_EOL, 24 | $stage === WorkflowState::STAGE_SUMMARY ? PHP_EOL : '', 25 | $this->indentation, 26 | ExecutionLog::mapStage($stage) 27 | ); 28 | 29 | foreach ($stageSteps as $step) { 30 | $debug .= "{$this->indentation} - " . $this->formatStep($step) . PHP_EOL; 31 | } 32 | } 33 | 34 | return trim($debug); 35 | } 36 | 37 | private function formatStep(Step $step): string 38 | { 39 | $stepLog = "{$step->getDescription()}: {$step->getState()}" . 40 | ($step->getReason() ? " ({$step->getReason()})" : '') . 41 | ($step->getWarnings() 42 | ? " ({$step->getWarnings()} warning" . ($step->getWarnings() > 1 ? 's' : '') . ")" 43 | : '' 44 | ); 45 | 46 | foreach ($step->getStepInfo() as $info) { 47 | $formattedInfo = $this->formatInfo($info); 48 | 49 | if ($formattedInfo) { 50 | $stepLog .= PHP_EOL . $formattedInfo; 51 | } 52 | } 53 | 54 | return $stepLog; 55 | } 56 | 57 | private function formatInfo(StepInfo $info): ?string 58 | { 59 | switch ($info->getInfo()) { 60 | case StepInfo::NESTED_WORKFLOW: 61 | /** @var WorkflowResult $nestedWorkflowResult */ 62 | $nestedWorkflowResult = $info->getContext()['result']; 63 | 64 | return "$this->indentation - " . str_replace( 65 | PHP_EOL . ' ' . PHP_EOL, 66 | PHP_EOL . PHP_EOL, 67 | str_replace(PHP_EOL, PHP_EOL . ' ', $nestedWorkflowResult->debug($this)) 68 | ); 69 | case StepInfo::LOOP_START: 70 | $this->indentation .= ' '; 71 | 72 | return null; 73 | case StepInfo::LOOP_ITERATION: 74 | return null; 75 | case StepInfo::LOOP_END: 76 | $this->indentation = substr($this->indentation, 0, -2); 77 | $iterations = $info->getContext()['iterations']; 78 | 79 | return " - Loop finished after $iterations iteration" . ($iterations === 1 ? '' : 's'); 80 | default: return "{$this->indentation} - " . $info->getInfo(); 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/State/ExecutionLog/OutputFormat/WorkflowGraph.php: -------------------------------------------------------------------------------- 1 | path = $path; 16 | } 17 | 18 | public function format(string $workflowName, array $steps): string 19 | { 20 | $this->generateImageFromScript( 21 | (new GraphViz())->format($workflowName, $steps), 22 | $filePath = $this->path . DIRECTORY_SEPARATOR . $workflowName . '_' . uniqid() . '.svg', 23 | ); 24 | 25 | return $filePath; 26 | } 27 | 28 | private function generateImageFromScript(string $script, string $file) 29 | { 30 | $tmp = tempnam(sys_get_temp_dir(), 'graphviz'); 31 | if ($tmp === false) { 32 | throw new Exception('Unable to get temporary file name for graphviz script'); 33 | } 34 | 35 | $ret = file_put_contents($tmp, $script, LOCK_EX); 36 | if ($ret === false) { 37 | throw new Exception('Unable to write graphviz script to temporary file'); 38 | } 39 | 40 | $ret = 0; 41 | 42 | system('dot -T svg ' . escapeshellarg($tmp) . ' -o ' . escapeshellarg($file), $ret); 43 | if ($ret !== 0) { 44 | throw new Exception('Unable to invoke "dot" to create image file (code ' . $ret . ')'); 45 | } 46 | 47 | unlink($tmp); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/State/ExecutionLog/Step.php: -------------------------------------------------------------------------------- 1 | step = $step; 18 | $this->state = $state; 19 | $this->reason = $reason; 20 | $this->stepInfo = $stepInfo; 21 | $this->warnings = $warnings; 22 | } 23 | 24 | public function getDescription(): string 25 | { 26 | return $this->step->getDescription(); 27 | } 28 | 29 | public function getState(): string 30 | { 31 | return $this->state; 32 | } 33 | 34 | public function getReason(): ?string 35 | { 36 | return $this->reason; 37 | } 38 | 39 | /** 40 | * @return StepInfo[] 41 | */ 42 | public function getStepInfo(): array 43 | { 44 | return $this->stepInfo; 45 | } 46 | 47 | public function getWarnings(): int 48 | { 49 | return $this->warnings; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/State/ExecutionLog/StepInfo.php: -------------------------------------------------------------------------------- 1 | info = $info; 20 | $this->context = $context; 21 | } 22 | 23 | public function getInfo(): string 24 | { 25 | return $this->info; 26 | } 27 | 28 | public function getContext(): array 29 | { 30 | return $this->context; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/State/ExecutionLog/Summary.php: -------------------------------------------------------------------------------- 1 | description = $description; 14 | } 15 | 16 | public function getDescription(): string 17 | { 18 | return $this->description; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/State/NestedContainer.php: -------------------------------------------------------------------------------- 1 | parentContainer = $parentContainer; 15 | $this->container = $container; 16 | } 17 | 18 | public function get(string $key) 19 | { 20 | if (!$this->container) { 21 | return $this->parentContainer->get($key); 22 | } 23 | 24 | return $this->container->get($key) ?? $this->parentContainer->get($key); 25 | } 26 | 27 | public function set(string $key, $value): WorkflowContainer 28 | { 29 | if ($this->container) { 30 | $this->container->set($key, $value); 31 | } 32 | 33 | $this->parentContainer->set($key, $value); 34 | 35 | return $this; 36 | } 37 | 38 | public function __call(string $name, array $arguments) 39 | { 40 | if ($this->container && method_exists($this->container, $name)) { 41 | return $this->container->{$name}(...$arguments); 42 | } 43 | 44 | // don't check if the method exists to trigger an error if the method neither exists in $container nor in 45 | // $parentContainer 46 | return $this->parentContainer->{$name}(...$arguments); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/State/WorkflowContainer.php: -------------------------------------------------------------------------------- 1 | items[$key] ?? null; 14 | } 15 | 16 | public function set(string $key, $value): self 17 | { 18 | $this->items[$key] = $value; 19 | return $this; 20 | } 21 | 22 | public function unset(string $key): self 23 | { 24 | unset($this->items[$key]); 25 | return $this; 26 | } 27 | 28 | public function has(string $key): bool 29 | { 30 | return array_key_exists($key, $this->items); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/State/WorkflowResult.php: -------------------------------------------------------------------------------- 1 | success = $success; 24 | $this->exception = $exception; 25 | $this->workflowState = $workflowState; 26 | } 27 | 28 | /** 29 | * Get the name of the executed workflow 30 | */ 31 | public function getWorkflowName(): string 32 | { 33 | return $this->workflowState->getWorkflowName(); 34 | } 35 | 36 | /** 37 | * Check if the workflow has been executed successfully 38 | */ 39 | public function success(): bool 40 | { 41 | return $this->success; 42 | } 43 | 44 | /** 45 | * Get the full debug log for the workflow execution 46 | */ 47 | public function debug(?OutputFormat $formatter = null) 48 | { 49 | return $this->workflowState->getExecutionLog()->debug($formatter ?? new StringLog()); 50 | } 51 | 52 | /** 53 | * Check if the workflow execution has triggered warnings 54 | */ 55 | public function hasWarnings(): bool 56 | { 57 | return count($this->workflowState->getExecutionLog()->getWarnings()) > 0; 58 | } 59 | 60 | /** 61 | * Get all warnings of the workflow execution. 62 | * Returns a nested array with all warnings grouped by stage (WorkflowState::STAGE_* constants). 63 | * 64 | * @return string[][] 65 | */ 66 | public function getWarnings(): array 67 | { 68 | return $this->workflowState->getExecutionLog()->getWarnings(); 69 | } 70 | 71 | /** 72 | * Returns the exception which lead to a failed workflow. 73 | * If the workflow was executed successfully null will be returned. 74 | */ 75 | public function getException(): ?Exception 76 | { 77 | return $this->exception; 78 | } 79 | 80 | /** 81 | * Get the container of the process 82 | */ 83 | public function getContainer(): WorkflowContainer 84 | { 85 | return $this->workflowState->getWorkflowContainer(); 86 | } 87 | 88 | /** 89 | * Get the last executed step of the workflow 90 | */ 91 | public function getLastStep(): WorkflowStep 92 | { 93 | return $this->workflowState->getCurrentStep(); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/State/WorkflowState.php: -------------------------------------------------------------------------------- 1 | executionLog = new ExecutionLog($this); 42 | $this->workflowControl = new WorkflowControl($this); 43 | $this->workflowContainer = $workflowContainer; 44 | 45 | self::$runningWorkflows[] = $this; 46 | } 47 | 48 | public function close(bool $success, ?Exception $exception = null): WorkflowResult 49 | { 50 | array_pop(self::$runningWorkflows); 51 | 52 | return new WorkflowResult($this, $success, $exception); 53 | } 54 | 55 | public static function getRunningWorkflow(): ?self 56 | { 57 | return self::$runningWorkflows ? end(self::$runningWorkflows) : null; 58 | } 59 | 60 | public function getProcessException(): ?Exception 61 | { 62 | return $this->processException; 63 | } 64 | 65 | public function setProcessException(?Exception $processException): void 66 | { 67 | $this->processException = $processException; 68 | } 69 | 70 | public function getStage(): int 71 | { 72 | return $this->stage; 73 | } 74 | 75 | public function setStage(int $stage): void 76 | { 77 | $this->stage = $stage; 78 | } 79 | 80 | public function getWorkflowName(): string 81 | { 82 | return $this->workflowName; 83 | } 84 | 85 | public function setWorkflowName(string $workflowName): void 86 | { 87 | $this->workflowName = $workflowName; 88 | } 89 | 90 | public function getWorkflowControl(): WorkflowControl 91 | { 92 | return $this->workflowControl; 93 | } 94 | 95 | public function getWorkflowContainer(): WorkflowContainer 96 | { 97 | return $this->workflowContainer; 98 | } 99 | 100 | public function addExecutionLog( 101 | Describable $step, 102 | string $state = ExecutionLog::STATE_SUCCESS, 103 | ?string $reason = null 104 | ): void { 105 | $this->executionLog->addStep($this->stage, $step, $state, $reason); 106 | } 107 | 108 | public function getExecutionLog(): ExecutionLog 109 | { 110 | return $this->executionLog; 111 | } 112 | 113 | public function setMiddlewares(array $middlewares): void 114 | { 115 | $this->middlewares = $middlewares; 116 | } 117 | 118 | public function getMiddlewares(): array 119 | { 120 | return $this->middlewares; 121 | } 122 | 123 | public function isInLoop(): bool 124 | { 125 | return $this->inLoop > 0; 126 | } 127 | 128 | public function setInLoop(bool $inLoop): void 129 | { 130 | $this->inLoop += $inLoop ? 1 : -1; 131 | } 132 | 133 | public function setStep(WorkflowStep $step): void 134 | { 135 | $this->currentStep = $step; 136 | } 137 | 138 | /** 139 | * @return WorkflowStep 140 | */ 141 | public function getCurrentStep(): WorkflowStep 142 | { 143 | return $this->currentStep; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/Step/Dependency/Requires.php: -------------------------------------------------------------------------------- 1 | has($this->key)) { 19 | throw new WorkflowStepDependencyNotFulfilledException("Missing '$this->key' in container"); 20 | } 21 | 22 | $value = $container->get($this->key); 23 | 24 | if ($this->type === null || (str_starts_with($this->type, '?') && $value === null)) { 25 | return; 26 | } 27 | 28 | $type = str_replace('?', '', $this->type); 29 | 30 | if (preg_match('/^(string|bool|int|float|object|array|iterable|scalar)$/', $type, $matches) === 1) { 31 | $checkMethod = 'is_' . $matches[1]; 32 | 33 | if ($checkMethod($value)) { 34 | return; 35 | } 36 | } elseif (class_exists($type) && ($value instanceof $type)) { 37 | return; 38 | } 39 | 40 | throw new WorkflowStepDependencyNotFulfilledException( 41 | sprintf( 42 | "Value for '%s' has an invalid type. Expected %s, got %s", 43 | $this->key, 44 | $this->type, 45 | gettype($value) . (is_object($value) ? sprintf(' (%s)', $value::class) : ''), 46 | ), 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Step/Dependency/StepDependencyInterface.php: -------------------------------------------------------------------------------- 1 | loopControl = $loopControl; 30 | $this->continueOnError = $continueOnError; 31 | } 32 | 33 | public function addStep(WorkflowStep $step): self 34 | { 35 | $this->steps[] = $step; 36 | 37 | return $this; 38 | } 39 | 40 | public function getDescription(): string 41 | { 42 | return $this->loopControl->getDescription(); 43 | } 44 | 45 | public function run(WorkflowControl $control, WorkflowContainer $container): void 46 | { 47 | $iteration = 0; 48 | 49 | WorkflowState::getRunningWorkflow()->setInLoop(true); 50 | $control->attachStepInfo(StepInfo::LOOP_START, ['description' => $this->loopControl->getDescription()]); 51 | WorkflowState::getRunningWorkflow()->addExecutionLog(new Summary('Start Loop')); 52 | 53 | while (true) { 54 | $loopState = ExecutionLog::STATE_SUCCESS; 55 | $reason = null; 56 | 57 | try { 58 | if (!$this->loopControl->executeNextIteration($iteration, $control, $container)) { 59 | break; 60 | } 61 | 62 | foreach ($this->steps as $step) { 63 | $this->wrapStepExecution($step, WorkflowState::getRunningWorkflow()); 64 | } 65 | 66 | $iteration++; 67 | } catch (Exception $exception) { 68 | $iteration++; 69 | $reason = $exception->getMessage(); 70 | 71 | if ($exception instanceof ContinueException) { 72 | $loopState = ExecutionLog::STATE_SKIPPED; 73 | } else { 74 | if ($exception instanceof BreakException) { 75 | WorkflowState::getRunningWorkflow()->addExecutionLog( 76 | new Summary("Loop iteration #$iteration"), 77 | ExecutionLog::STATE_SKIPPED, 78 | $reason, 79 | ); 80 | 81 | $control->attachStepInfo("Loop break in iteration #$iteration"); 82 | 83 | break; 84 | } 85 | 86 | if (!$this->continueOnError || 87 | $exception instanceof SkipWorkflowException || 88 | $exception instanceof FailWorkflowException 89 | ) { 90 | $control->attachStepInfo(StepInfo::LOOP_ITERATION, ['iteration' => $iteration]); 91 | $control->attachStepInfo(StepInfo::LOOP_END, ['iterations' => $iteration]); 92 | 93 | throw $exception; 94 | } 95 | 96 | $control->warning("Loop iteration #$iteration failed. Continued execution."); 97 | $loopState = ExecutionLog::STATE_FAILED; 98 | } 99 | } 100 | 101 | $control->attachStepInfo(StepInfo::LOOP_ITERATION, ['iteration' => $iteration]); 102 | 103 | WorkflowState::getRunningWorkflow() 104 | ->addExecutionLog(new Summary("Loop iteration #$iteration"), $loopState, $reason); 105 | } 106 | WorkflowState::getRunningWorkflow()->setInLoop(false); 107 | 108 | $control->attachStepInfo(StepInfo::LOOP_END, ['iterations' => $iteration]); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Step/LoopControl.php: -------------------------------------------------------------------------------- 1 | nestedWorkflow = $nestedWorkflow; 24 | $this->container = $container; 25 | } 26 | 27 | public function getDescription(): string 28 | { 29 | return "Execute nested workflow"; 30 | } 31 | 32 | public function run(WorkflowControl $control, WorkflowContainer $container): void 33 | { 34 | try { 35 | $this->workflowResult = $this->nestedWorkflow->executeWorkflow( 36 | new NestedContainer($container, $this->container), 37 | // TODO: array unpacking via named arguments when dropping PHP7 support 38 | $container->get('__internalExecutionConfiguration')['throwOnFailure'], 39 | ); 40 | } catch (WorkflowException $exception) { 41 | $this->workflowResult = $exception->getWorkflowResult(); 42 | } 43 | 44 | $control->attachStepInfo(StepInfo::NESTED_WORKFLOW, ['result' => $this->workflowResult]); 45 | 46 | if ($this->workflowResult->getWarnings()) { 47 | $warnings = count($this->workflowResult->getWarnings(), COUNT_RECURSIVE) - 48 | count($this->workflowResult->getWarnings()); 49 | 50 | $control->warning( 51 | sprintf( 52 | "Nested workflow '%s' emitted %s warning%s", 53 | $this->workflowResult->getWorkflowName(), 54 | $warnings, 55 | $warnings > 1 ? 's' : '', 56 | ), 57 | ); 58 | } 59 | 60 | if (!$this->workflowResult->success()) { 61 | $control->failStep("Nested workflow '{$this->workflowResult->getWorkflowName()}' failed"); 62 | } 63 | } 64 | 65 | public function getNestedWorkflowResult(): WorkflowResult 66 | { 67 | return $this->workflowResult; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Step/StepExecutionTrait.php: -------------------------------------------------------------------------------- 1 | resolveMiddleware($step, $workflowState))(); 21 | } catch (SkipStepException | FailStepException $exception) { 22 | $workflowState->addExecutionLog( 23 | $step, 24 | $exception instanceof FailStepException ? ExecutionLog::STATE_FAILED : ExecutionLog::STATE_SKIPPED, 25 | $exception->getMessage(), 26 | ); 27 | 28 | if ($exception instanceof FailStepException) { 29 | // cancel the workflow during preparation 30 | if ($workflowState->getStage() <= WorkflowState::STAGE_PROCESS) { 31 | throw $exception; 32 | } 33 | 34 | $workflowState->getExecutionLog()->addWarning(sprintf('Step failed (%s)', get_class($step)), true); 35 | } 36 | 37 | // bubble up the exception so the loop control can handle the exception 38 | if ($exception instanceof LoopControlException) { 39 | throw $exception; 40 | } 41 | 42 | return; 43 | } catch (Exception $exception) { 44 | $workflowState->addExecutionLog( 45 | $step, 46 | $exception instanceof SkipWorkflowException ? ExecutionLog::STATE_SKIPPED : ExecutionLog::STATE_FAILED, 47 | $exception->getMessage(), 48 | ); 49 | 50 | // cancel the workflow during preparation 51 | if ($workflowState->getStage() <= WorkflowState::STAGE_PROCESS) { 52 | throw $exception; 53 | } 54 | 55 | if (!($exception instanceof SkipWorkflowException)) { 56 | $workflowState->getExecutionLog()->addWarning(sprintf('Step failed (%s)', get_class($step)), true); 57 | } 58 | 59 | return; 60 | } 61 | 62 | $workflowState->addExecutionLog($step); 63 | } 64 | 65 | private function resolveMiddleware(WorkflowStep $step, WorkflowState $workflowState): callable 66 | { 67 | $tip = fn () => $step->run($workflowState->getWorkflowControl(), $workflowState->getWorkflowContainer()); 68 | 69 | $middlewares = $workflowState->getMiddlewares(); 70 | 71 | if (PHP_MAJOR_VERSION >= 8) { 72 | array_unshift($middlewares, new WorkflowStepDependencyCheck()); 73 | } 74 | 75 | foreach ($middlewares as $middleware) { 76 | $tip = fn () => $middleware( 77 | $tip, 78 | $workflowState->getWorkflowControl(), 79 | $workflowState->getWorkflowContainer(), 80 | $step, 81 | ); 82 | } 83 | 84 | return $tip; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Step/WorkflowStep.php: -------------------------------------------------------------------------------- 1 | step = $step; 17 | $this->hardValidator = $hardValidator; 18 | } 19 | 20 | public function getStep(): WorkflowStep 21 | { 22 | return $this->step; 23 | } 24 | 25 | public function isHardValidator(): bool 26 | { 27 | return $this->hardValidator; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Workflow.php: -------------------------------------------------------------------------------- 1 | name = $name; 30 | $this->middleware = $middlewares; 31 | } 32 | 33 | protected function runStage(WorkflowState $workflowState): ?Stage 34 | { 35 | $workflowState->setWorkflowName($this->name); 36 | $workflowState->setMiddlewares($this->middleware); 37 | 38 | $nextStage = $this->nextStage; 39 | while ($nextStage) { 40 | $nextStage = $nextStage->runStage($workflowState); 41 | } 42 | 43 | if ($workflowState->getProcessException()) { 44 | throw $workflowState->getProcessException(); 45 | } 46 | 47 | return null; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/WorkflowControl.php: -------------------------------------------------------------------------------- 1 | workflowState = $workflowState; 23 | } 24 | 25 | /** 26 | * Mark the current step as skipped. 27 | * Use this if you detect, that the step execution is not necessary 28 | * (e.g. disabled by config, no entity to process, ...) 29 | * 30 | * @param string $reason 31 | */ 32 | public function skipStep(string $reason): void 33 | { 34 | throw new SkipStepException($reason); 35 | } 36 | 37 | /** 38 | * Mark the current step as failed. 39 | * A failed step before and during the processing of a workflow leads to a failed workflow. 40 | * 41 | * @param string $reason 42 | */ 43 | public function failStep(string $reason): void 44 | { 45 | throw new FailStepException($reason); 46 | } 47 | 48 | /** 49 | * Mark the workflow as failed. 50 | * If the workflow is failed after the process stage has been executed it's handled like a failed step. 51 | * 52 | * @param string $reason 53 | */ 54 | public function failWorkflow(string $reason): void 55 | { 56 | throw new FailWorkflowException($reason); 57 | } 58 | 59 | /** 60 | * Skip the further workflow execution (e.g. if you detect it's not necessary to process the workflow). 61 | * If the workflow is skipped after the process stage has been executed it's handled like a skipped step. 62 | * 63 | * @param string $reason 64 | */ 65 | public function skipWorkflow(string $reason): void 66 | { 67 | throw new SkipWorkflowException($reason); 68 | } 69 | 70 | /** 71 | * If in a loop the current iteration is cancelled and the next iteration is started. If the step is not part of a 72 | * loop the step is skipped. 73 | * 74 | * @param string $reason 75 | */ 76 | public function continue(string $reason): void 77 | { 78 | if ($this->workflowState->isInLoop()) { 79 | throw new ContinueException($reason); 80 | } 81 | 82 | $this->skipStep($reason); 83 | } 84 | 85 | /** 86 | * If in a loop the loop is cancelled and the next step after the loop is executed. If the step is not part of a 87 | * loop the step is skipped. 88 | * 89 | * @param string $reason 90 | */ 91 | public function break(string $reason): void 92 | { 93 | if ($this->workflowState->isInLoop()) { 94 | throw new BreakException($reason); 95 | } 96 | 97 | $this->skipStep($reason); 98 | } 99 | 100 | /** 101 | * Attach any additional debug info to your current step. 102 | * Info will be shown in the workflow debug log. 103 | * 104 | * @param string $info 105 | * @param array $context May contain additional information which is evaluated in a custom output formatter 106 | */ 107 | public function attachStepInfo(string $info, array $context = []): void 108 | { 109 | $this->workflowState->getExecutionLog()->attachStepInfo($info, $context); 110 | } 111 | 112 | /** 113 | * Add a warning to the workflow. 114 | * All warnings will be collected and shown in the workflow debug log. 115 | * 116 | * @param string $message 117 | * @param Exception|null $exception An exception causing the warning 118 | * (if provided exception information will be added to the warning) 119 | */ 120 | public function warning(string $message, ?Exception $exception = null): void 121 | { 122 | if ($exception) { 123 | $exceptionClass = get_class($exception); 124 | $message .= sprintf( 125 | " (%s%s in %s::%s)", 126 | strstr($exceptionClass, '\\') ? trim(strrchr($exceptionClass, '\\'), '\\') : $exceptionClass, 127 | ($exception->getMessage() ? ": {$exception->getMessage()}" : ''), 128 | $exception->getFile(), 129 | $exception->getLine(), 130 | ); 131 | } 132 | 133 | $this->workflowState->getExecutionLog()->addWarning($message); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /tests/LoopTest.php: -------------------------------------------------------------------------------- 1 | set('entries', ['a', 'b', 'c']); 22 | 23 | $result = (new Workflow('test')) 24 | ->process( 25 | (new Loop( 26 | $this->setupLoop( 27 | 'process-loop', 28 | fn (WorkflowControl $control, WorkflowContainer $container) => 29 | !empty($container->get('entries')) 30 | ), 31 | ))->addStep( 32 | $this->setupStep( 33 | 'process-test', 34 | function (WorkflowControl $control, WorkflowContainer $container) { 35 | $entries = $container->get('entries'); 36 | $entry = array_shift($entries); 37 | $container->set('entries', $entries); 38 | 39 | $control->attachStepInfo("Process entry $entry"); 40 | }, 41 | ) 42 | ) 43 | ) 44 | ->executeWorkflow($container); 45 | 46 | $this->assertTrue($result->success()); 47 | $this->assertDebugLog( 48 | <<process( 76 | (new Loop($this->setupLoop('process-loop', fn () => false))) 77 | ->addStep($this->setupEmptyStep('process-test')), 78 | ) 79 | ->executeWorkflow(); 80 | 81 | $this->assertTrue($result->success()); 82 | $this->assertDebugLog( 83 | <<set('entries', ['a', 'b']); 101 | 102 | $result = (new Workflow('test')) 103 | ->process( 104 | (new Loop($this->entryLoopControl())) 105 | ->addStep($this->processEntry()) 106 | ->addStep($this->setupEmptyStep('process-test-2')), 107 | ) 108 | ->executeWorkflow($container); 109 | 110 | $this->assertTrue($result->success()); 111 | $this->assertDebugLog( 112 | <<set('entries', ['a', 'b']); 138 | 139 | $result = (new Workflow('test')) 140 | ->process( 141 | (new Loop($this->entryLoopControl())) 142 | ->addStep( 143 | new NestedWorkflow( 144 | (new Workflow('nested-workflow'))->process($this->processEntry()), 145 | ), 146 | ), 147 | ) 148 | ->executeWorkflow($container); 149 | 150 | $this->assertTrue($result->success()); 151 | $this->assertDebugLog( 152 | <<set('entries', ['a', 'b', 'c']); 190 | 191 | $result = (new Workflow('test')) 192 | ->process( 193 | (new Loop($this->entryLoopControl())) 194 | ->addStep( 195 | $this->setupStep( 196 | 'process-test', 197 | function (WorkflowControl $control, WorkflowContainer $container) { 198 | if ($container->get('entry') === 'b') { 199 | $control->continue('Skip reason'); 200 | } 201 | 202 | $control->attachStepInfo('Process entry'); 203 | }, 204 | ) 205 | ) 206 | ->addStep($this->setupEmptyStep('Post-Process entry')) 207 | ) 208 | ->executeWorkflow($container); 209 | 210 | $this->assertTrue($result->success()); 211 | $this->assertDebugLog( 212 | <<set('entries', ['a', 'b', 'c']); 240 | 241 | $result = (new Workflow('test')) 242 | ->process( 243 | (new Loop( 244 | $this->setupLoop( 245 | 'process-loop', 246 | function (WorkflowControl $control, WorkflowContainer $container) { 247 | $entries = $container->get('entries'); 248 | 249 | if (empty($entries)) { 250 | return false; 251 | } 252 | 253 | $entry = array_shift($entries); 254 | $container->set('entries', $entries); 255 | 256 | if ($entry === 'b') { 257 | $control->continue('Skip reason'); 258 | } 259 | 260 | return true; 261 | }, 262 | ) 263 | )) 264 | ->addStep($this->setupEmptyStep('process 1')) 265 | ->addStep($this->setupEmptyStep('process 2')) 266 | ) 267 | ->executeWorkflow($container); 268 | 269 | $this->assertTrue($result->success()); 270 | $this->assertDebugLog( 271 | <<set('entries', ['a', 'b', 'c']); 296 | 297 | $result = (new Workflow('test')) 298 | ->process( 299 | (new Loop($this->entryLoopControl())) 300 | ->addStep( 301 | $this->setupStep( 302 | 'process-test', 303 | function (WorkflowControl $control, WorkflowContainer $container) { 304 | if ($container->get('entry') === 'b') { 305 | $control->break('break reason'); 306 | } 307 | 308 | $control->attachStepInfo('Process entry'); 309 | }, 310 | ) 311 | ) 312 | ->addStep($this->setupEmptyStep('Post-Process entry')) 313 | ) 314 | ->executeWorkflow($container); 315 | 316 | $this->assertTrue($result->success()); 317 | $this->assertDebugLog( 318 | <<set('entries', ['a', 'b', 'c']); 343 | 344 | $result = (new Workflow('test')) 345 | ->process( 346 | (new Loop( 347 | $this->setupLoop( 348 | 'process-loop', 349 | function (WorkflowControl $control, WorkflowContainer $container) { 350 | $entries = $container->get('entries'); 351 | 352 | if (empty($entries)) { 353 | return false; 354 | } 355 | 356 | $entry = array_shift($entries); 357 | $container->set('entries', $entries); 358 | 359 | if ($entry === 'b') { 360 | $control->break('Break reason'); 361 | } 362 | 363 | return true; 364 | }, 365 | ) 366 | )) 367 | ->addStep($this->setupEmptyStep('process 1')) 368 | ->addStep($this->setupEmptyStep('process 2')) 369 | ) 370 | ->executeWorkflow($container); 371 | 372 | $this->assertTrue($result->success()); 373 | $this->assertDebugLog( 374 | <<set('entries', ['a', null, 'c']); 397 | 398 | $result = (new Workflow('test')) 399 | ->process( 400 | (new Loop($this->entryLoopControl())) 401 | ->addStep( 402 | $this->setupStep( 403 | 'process-test', 404 | function (WorkflowControl $control, WorkflowContainer $container) { 405 | if (!$container->get('entry')) { 406 | $control->skipStep('no entry'); 407 | } 408 | }, 409 | ) 410 | ) 411 | ) 412 | ->executeWorkflow($container); 413 | 414 | $this->assertTrue($result->success()); 415 | $this->assertDebugLog( 416 | <<process( 441 | (new Loop( 442 | $this->setupLoop( 443 | 'process-loop', 444 | fn (WorkflowControl $control) => $control->skipStep('skip reason'), 445 | ) 446 | )) 447 | ->addStep($this->processEntry()) 448 | ) 449 | ->executeWorkflow(); 450 | 451 | $this->assertTrue($result->success()); 452 | $this->assertDebugLog( 453 | <<set('entries', ['a', null, 'c']); 475 | 476 | $result = (new Workflow('test')) 477 | ->process( 478 | (new Loop($this->entryLoopControl(), $continueOnError)) 479 | ->addStep( 480 | $this->setupStep( 481 | 'process-test', 482 | function (WorkflowControl $control, WorkflowContainer $container) { 483 | if (!$container->get('entry')) { 484 | $control->skipWorkflow('no entry'); 485 | } 486 | }, 487 | ) 488 | ) 489 | ) 490 | ->executeWorkflow($container); 491 | 492 | $this->assertTrue($result->success()); 493 | $this->assertDebugLog( 494 | <<process( 520 | (new Loop( 521 | $this->setupLoop( 522 | 'process-loop', 523 | fn (WorkflowControl $control) => $control->skipWorkflow('skip reason'), 524 | ), 525 | $continueOnError, 526 | )) 527 | ->addStep($this->processEntry()) 528 | ) 529 | ->executeWorkflow(); 530 | 531 | $this->assertTrue($result->success()); 532 | $this->assertDebugLog( 533 | <<set('entries', ['a', null, 'c']); 554 | 555 | $result = (new Workflow('test')) 556 | ->process( 557 | (new Loop($this->entryLoopControl())) 558 | ->addStep( 559 | $this->setupStep( 560 | 'process-test', 561 | function (WorkflowControl $control, WorkflowContainer $container) use ($failingStep) { 562 | if (!$container->get('entry')) { 563 | $control->attachStepInfo( 564 | sprintf('got value %s', var_export($container->get('entry'), true)) 565 | ); 566 | 567 | $failingStep($control); 568 | } 569 | }, 570 | ) 571 | ) 572 | ) 573 | ->executeWorkflow($container, false); 574 | 575 | $this->assertFalse($result->success()); 576 | $this->assertNotNull($result->getException()); 577 | $this->assertDebugLog( 578 | <<set('entries', ['a', null, 'c']); 603 | 604 | $result = (new Workflow('test')) 605 | ->process( 606 | (new Loop($this->entryLoopControl(), true)) 607 | ->addStep( 608 | $this->setupStep( 609 | 'process-test', 610 | function (WorkflowControl $control, WorkflowContainer $container) use ($failingStep) { 611 | if (!$container->get('entry')) { 612 | $failingStep($control); 613 | } 614 | }, 615 | ) 616 | ) 617 | ) 618 | ->executeWorkflow($container, false); 619 | 620 | $this->assertTrue($result->success()); 621 | $this->assertDebugLog( 622 | <<set('entries', ['a', null, 'c']); 648 | 649 | $result = (new Workflow('test')) 650 | ->process( 651 | (new Loop($this->entryLoopControl(), true)) 652 | ->addStep( 653 | $this->setupStep( 654 | 'process-test', 655 | function (WorkflowControl $control, WorkflowContainer $container): void { 656 | if (!$container->get('entry')) { 657 | $control->failWorkflow('Fail Message'); 658 | } 659 | }, 660 | ) 661 | ) 662 | ) 663 | ->executeWorkflow($container, false); 664 | 665 | $this->assertFalse($result->success()); 666 | $this->assertNotNull($result->getException()); 667 | $this->assertDebugLog( 668 | <<set('entries', ['a', null, 'c']); 692 | 693 | $result = (new Workflow('test')) 694 | ->process( 695 | (new Loop($this->setupLoop('process-loop', $failingStep))) 696 | ->addStep($this->processEntry()) 697 | ) 698 | ->executeWorkflow($container, false); 699 | 700 | $this->assertFalse($result->success()); 701 | $this->assertNotNull($result->getException()); 702 | $this->assertDebugLog( 703 | <<set('entries', ['a', null, 'c']); 724 | 725 | $result = (new Workflow('test')) 726 | ->process( 727 | (new Loop( 728 | $this->setupLoop( 729 | 'process-loop', 730 | function (WorkflowControl $control, WorkflowContainer $container) use ($failingStep): bool { 731 | $entries = $container->get('entries'); 732 | 733 | if (empty($entries)) { 734 | return false; 735 | } 736 | 737 | $container->set('entry', array_shift($entries)); 738 | $container->set('entries', $entries); 739 | 740 | if (!$container->get('entry')) { 741 | $failingStep($control); 742 | } 743 | 744 | return true; 745 | }, 746 | ), 747 | true, 748 | )) 749 | ->addStep($this->processEntry()) 750 | ) 751 | ->executeWorkflow($container, false); 752 | 753 | $this->assertTrue($result->success()); 754 | 755 | $this->assertDebugLog( 756 | << [false], 784 | "Continue on failure" => [true], 785 | ]; 786 | } 787 | 788 | 789 | public function failStepDataProvider(): array 790 | { 791 | return [ 792 | 'By Exception' => [function (): void { 793 | throw new InvalidArgumentException('Fail Message'); 794 | }], 795 | 'By failing step' => [fn (WorkflowControl $control) => $control->failStep('Fail Message')], 796 | ]; 797 | } 798 | } 799 | -------------------------------------------------------------------------------- /tests/NestedWorkflowTest.php: -------------------------------------------------------------------------------- 1 | set('testdata', 'Hello World'); 21 | 22 | $result = (new Workflow('test')) 23 | ->before(new NestedWorkflow( 24 | (new Workflow('nested-test')) 25 | ->before($this->setupStep( 26 | 'nested-before-test', 27 | fn (WorkflowControl $control, WorkflowContainer $container) => 28 | $container->set('additional-data', 'from-nested'), 29 | )) 30 | ->process($this->setupStep( 31 | 'nested-process-test', 32 | function (WorkflowControl $control, WorkflowContainer $container) { 33 | $control->warning('nested-warning-message'); 34 | $control->attachStepInfo($container->get('testdata')); 35 | }, 36 | )) 37 | )) 38 | ->process($this->setupStep( 39 | 'process-test', 40 | function (WorkflowControl $control, WorkflowContainer $container) { 41 | $control->warning('warning-message'); 42 | $control->attachStepInfo($container->get('additional-data')); 43 | }, 44 | )) 45 | ->executeWorkflow($container); 46 | 47 | $this->assertTrue($result->success()); 48 | $this->assertDebugLog( 49 | <<set('testdata', 'Hello World'); 83 | 84 | $result = (new Workflow('test')) 85 | ->before(new NestedWorkflow( 86 | (new Workflow('nested-test')) 87 | ->process($this->setupStep( 88 | 'nested-process-test', 89 | function () { 90 | throw new InvalidArgumentException('exception-message'); 91 | }, 92 | )) 93 | )) 94 | ->process($this->setupEmptyStep('process-test')) 95 | ->executeWorkflow($container, false); 96 | 97 | $this->assertFalse($result->success()); 98 | $this->assertDebugLog( 99 | <<getLastStep(); 120 | $this->assertInstanceOf(NestedWorkflow::class, $lastStep); 121 | $nestedResult = $lastStep->getNestedWorkflowResult(); 122 | 123 | $this->assertFalse($nestedResult->success()); 124 | } 125 | 126 | public function testNestedWorkflowHasMergedContainer(): void 127 | { 128 | $parentContainer = new class () extends WorkflowContainer { 129 | public function getParentData(): string 130 | { 131 | return 'parent-data'; 132 | } 133 | }; 134 | $parentContainer->set('set-parent', 'set-parent-data'); 135 | 136 | $nestedContainer = new class () extends WorkflowContainer { 137 | public function getNestedData(): string 138 | { 139 | return 'nested-data'; 140 | } 141 | }; 142 | $nestedContainer->set('set-nested', 'set-nested-data'); 143 | 144 | 145 | $result = (new Workflow('test')) 146 | ->before(new NestedWorkflow( 147 | (new Workflow('nested-test')) 148 | ->before($this->setupStep( 149 | 'nested-before-test', 150 | function (WorkflowControl $control, WorkflowContainer $container) { 151 | $control->attachStepInfo($container->getParentData()); 152 | $control->attachStepInfo($container->getNestedData()); 153 | $control->attachStepInfo($container->get('set-parent')); 154 | $control->attachStepInfo($container->get('set-nested')); 155 | 156 | $container->set('additional-data', 'from-nested'); 157 | }, 158 | )) 159 | ->process($this->setupStep( 160 | 'nested-process-test', 161 | function (WorkflowControl $control, WorkflowContainer $container) { 162 | $control->attachStepInfo($container->get('additional-data')); 163 | }, 164 | )), 165 | $nestedContainer, 166 | )) 167 | ->process($this->setupStep( 168 | 'process-test', 169 | function (WorkflowControl $control, WorkflowContainer $container) { 170 | $control->attachStepInfo($container->get('additional-data')); 171 | $control->attachStepInfo($container->get('set-parent')); 172 | $control->attachStepInfo(var_export($container->get('set-nested'), true)); 173 | }, 174 | )) 175 | ->executeWorkflow($parentContainer); 176 | 177 | $this->assertTrue($result->success()); 178 | $this->assertDebugLog( 179 | <<assertSame('from-nested', $result->getContainer()->get('additional-data')); 212 | $this->assertSame('set-parent-data', $result->getContainer()->get('set-parent')); 213 | $this->assertNull($result->getContainer()->get('set-nested')); 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /tests/OutputFormatterTest.php: -------------------------------------------------------------------------------- 1 | process($this->setupEmptyStep('process-test')) 29 | ->executeWorkflow(); 30 | 31 | $this->assertTrue($result->success()); 32 | $this->assertFalse($result->hasWarnings()); 33 | $this->assertEmpty($result->getWarnings()); 34 | $this->assertNull($result->getException()); 35 | $this->assertSame('test', $result->getWorkflowName()); 36 | 37 | $this->assertDebugLog( 38 | << shape="box" color="green"] 44 | } 45 | subgraph cluster_1 { 46 | label = "Summary" 47 | 2 [label=Execution time: *> shape="box" color="green"] 48 | } 49 | 0 -> 1 50 | 1 -> 2 51 | } 52 | DEBUG, 53 | $result, 54 | new GraphViz(), 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tests/StepDependencyTest.php: -------------------------------------------------------------------------------- 1 | = 8.0.0'); 24 | } 25 | } 26 | 27 | public function testMissingKeyFails(): void 28 | { 29 | $result = (new Workflow('test')) 30 | ->process($this->requireCustomerIdStep()) 31 | ->executeWorkflow(null, false); 32 | 33 | $this->assertFalse($result->success()); 34 | 35 | $this->assertDebugLog( 36 | <<process($this->requireCustomerIdStep()) 56 | ->executeWorkflow((new WorkflowContainer())->set('customerId', $input)); 57 | 58 | $this->assertTrue($result->success()); 59 | 60 | $type = gettype($input); 61 | $this->assertDebugLog( 62 | << [null], 80 | 'int' => [10], 81 | 'float' => [10.5], 82 | 'bool' => [true], 83 | 'array' => [[1, 2, 3]], 84 | 'string' => ['Hello'], 85 | 'DateTime' => [new DateTime()], 86 | ]; 87 | } 88 | 89 | /** 90 | * @dataProvider invalidTypedValueDataProvider 91 | */ 92 | public function testInvalidTypedValueFails(WorkflowContainer $container, string $expectedExceptionMessage): void 93 | { 94 | $result = (new Workflow('test')) 95 | ->process($this->requiredTypedCustomerIdStep()) 96 | ->executeWorkflow($container, false); 97 | 98 | $this->assertFalse($result->success()); 99 | 100 | $this->assertDebugLog( 101 | << [(new WorkflowContainer()), "Missing 'customerId' in container"]; 117 | 118 | foreach ([null, false, 10, 0.0, []] as $input) { 119 | yield gettype($input) => [ 120 | (new WorkflowContainer())->set('customerId', $input), 121 | "Value for 'customerId' has an invalid type. Expected string, got " . gettype($input), 122 | ]; 123 | } 124 | } 125 | 126 | public function testProvidedTypedKeySucceeds(): void 127 | { 128 | $result = (new Workflow('test')) 129 | ->process($this->requiredTypedCustomerIdStep()) 130 | ->executeWorkflow((new WorkflowContainer())->set('customerId', 'Hello')); 131 | 132 | $this->assertTrue($result->success()); 133 | 134 | $this->assertDebugLog( 135 | <<process($this->requiredNullableTypedCustomerIdStep()) 157 | ->executeWorkflow($container, false); 158 | 159 | $this->assertFalse($result->success()); 160 | 161 | $this->assertDebugLog( 162 | << [(new WorkflowContainer()), "Missing 'customerId' in container"]; 178 | 179 | foreach ([false, 10, 0.0, []] as $input) { 180 | yield gettype($input) => [ 181 | (new WorkflowContainer())->set('customerId', $input), 182 | "Value for 'customerId' has an invalid type. Expected ?string, got " . gettype($input), 183 | ]; 184 | } 185 | } 186 | 187 | /** 188 | * @dataProvider providedNullableTypedKeyDataProvider 189 | */ 190 | public function testProvidedNullableTypedKeySucceeds(?string $input): void 191 | { 192 | $result = (new Workflow('test')) 193 | ->process($this->requiredNullableTypedCustomerIdStep()) 194 | ->executeWorkflow((new WorkflowContainer())->set('customerId', $input)); 195 | 196 | $this->assertTrue($result->success()); 197 | 198 | $this->assertDebugLog( 199 | << [null], 216 | 'empty string' => [''], 217 | 'numeric string' => ['123'], 218 | 'string' => ['Hello World'], 219 | ]; 220 | } 221 | 222 | /** 223 | * @dataProvider invalidDateTimeValueDataProvider 224 | */ 225 | public function testInvalidDateTimeValueFails( 226 | WorkflowContainer $container, 227 | string $expectedExceptionMessage 228 | ): void { 229 | $result = (new Workflow('test')) 230 | ->process($this->requiredDateTimeStep()) 231 | ->executeWorkflow($container, false); 232 | 233 | $this->assertFalse($result->success()); 234 | 235 | $this->assertDebugLog( 236 | << [(new WorkflowContainer()), "Missing 'created' in container"]; 252 | yield 'updated not provided' => [ 253 | (new WorkflowContainer())->set('created', new DateTime()), 254 | "Missing 'updated' in container", 255 | ]; 256 | 257 | foreach ([null, false, 10, 0.0, [], '', new DateTimeZone('Europe/Berlin')] as $input) { 258 | yield 'Invalid value for created - ' . gettype($input) => [ 259 | (new WorkflowContainer())->set('created', $input), 260 | "Value for 'created' has an invalid type. Expected DateTime, got " 261 | . gettype($input) 262 | . (is_object($input) ? sprintf(' (%s)', get_class($input)) : ''), 263 | ]; 264 | } 265 | 266 | foreach ([false, 10, 0.0, [], '', new DateTimeZone('Europe/Berlin')] as $input) { 267 | yield 'Invalid value for updated - ' . gettype($input) => [ 268 | (new WorkflowContainer())->set('created', new DateTime())->set('updated', $input), 269 | "Value for 'updated' has an invalid type. Expected ?DateTime, got " 270 | . gettype($input) 271 | . (is_object($input) ? sprintf(' (%s)', get_class($input)) : ''), 272 | ]; 273 | } 274 | } 275 | 276 | 277 | /** 278 | * @dataProvider providedDateTimeDataProvider 279 | */ 280 | public function testProvidedDateTimeSucceeds(DateTime $created, ?DateTime $updated): void 281 | { 282 | $result = (new Workflow('test')) 283 | ->process($this->requiredDateTimeStep()) 284 | ->executeWorkflow((new WorkflowContainer())->set('created', $created)->set('updated', $updated)); 285 | 286 | $this->assertTrue($result->success()); 287 | 288 | $this->assertDebugLog( 289 | << [new DateTime(), null], 306 | 'created and updated' => [new DateTime(), new DateTime()], 307 | ]; 308 | } 309 | 310 | public function requireCustomerIdStep(): WorkflowStep 311 | { 312 | return new class () implements WorkflowStep { 313 | public function getDescription(): string 314 | { 315 | return 'test step with simple require'; 316 | } 317 | 318 | public function run( 319 | WorkflowControl $control, 320 | #[Requires('customerId')] 321 | WorkflowContainer $container 322 | ): void { 323 | $control->attachStepInfo('provided type: ' . gettype($container->get('customerId'))); 324 | } 325 | }; 326 | } 327 | 328 | public function requiredTypedCustomerIdStep(): WorkflowStep 329 | { 330 | return new class () implements WorkflowStep { 331 | public function getDescription(): string 332 | { 333 | return 'test step with typed require'; 334 | } 335 | 336 | public function run( 337 | WorkflowControl $control, 338 | #[Requires('customerId', 'string')] 339 | WorkflowContainer $container 340 | ): void {} 341 | }; 342 | } 343 | 344 | public function requiredNullableTypedCustomerIdStep(): WorkflowStep 345 | { 346 | return new class () implements WorkflowStep { 347 | public function getDescription(): string 348 | { 349 | return 'test step with nullable typed require'; 350 | } 351 | 352 | public function run( 353 | WorkflowControl $control, 354 | #[Requires('customerId', '?string')] 355 | WorkflowContainer $container 356 | ): void {} 357 | }; 358 | } 359 | 360 | public function requiredDateTimeStep(): WorkflowStep 361 | { 362 | return new class () implements WorkflowStep { 363 | public function getDescription(): string 364 | { 365 | return 'test step with DateTime require'; 366 | } 367 | 368 | public function run( 369 | WorkflowControl $control, 370 | #[Requires('created', DateTime::class)] 371 | #[Requires('updated', '?' . DateTime::class)] 372 | WorkflowContainer $container 373 | ): void {} 374 | }; 375 | } 376 | } 377 | -------------------------------------------------------------------------------- /tests/WorkflowContainerTest.php: -------------------------------------------------------------------------------- 1 | assertFalse($container->has('non existing key')); 17 | $this->assertNull($container->get('non existing key')); 18 | 19 | $container->set('key', 42); 20 | $this->assertTrue($container->has('key')); 21 | $this->assertSame(42, $container->get('key')); 22 | 23 | $container->set('key', 'Updated'); 24 | $this->assertTrue($container->has('key')); 25 | $this->assertSame('Updated', $container->get('key')); 26 | 27 | $container->unset('key'); 28 | $this->assertFalse($container->has('key')); 29 | $this->assertNull($container->get('key')); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/WorkflowSetupTrait.php: -------------------------------------------------------------------------------- 1 | setupStep($description, fn () => null); 18 | } 19 | 20 | private function setupStep(string $description, callable $callable): WorkflowStep 21 | { 22 | return new class ($description, $callable) implements WorkflowStep { 23 | private string $description; 24 | private $callable; 25 | 26 | public function __construct(string $description, callable $callable) 27 | { 28 | $this->description = $description; 29 | $this->callable = $callable; 30 | } 31 | 32 | public function getDescription(): string 33 | { 34 | return $this->description; 35 | } 36 | 37 | public function run(WorkflowControl $control, WorkflowContainer $container): void 38 | { 39 | ($this->callable)($control, $container); 40 | } 41 | }; 42 | } 43 | 44 | private function setupLoop(string $description, callable $callable): LoopControl 45 | { 46 | return new class ($description, $callable) implements LoopControl { 47 | private string $description; 48 | private $callable; 49 | 50 | public function __construct(string $description, callable $callable) 51 | { 52 | $this->description = $description; 53 | $this->callable = $callable; 54 | } 55 | 56 | public function getDescription(): string 57 | { 58 | return $this->description; 59 | } 60 | public function executeNextIteration( 61 | int $iteration, 62 | WorkflowControl $control, 63 | WorkflowContainer $container 64 | ): bool { 65 | return ($this->callable)($control, $container); 66 | } 67 | }; 68 | } 69 | 70 | private function entryLoopControl(): LoopControl 71 | { 72 | return $this->setupLoop( 73 | 'process-loop', 74 | function (WorkflowControl $control, WorkflowContainer $container): bool { 75 | $entries = $container->get('entries'); 76 | 77 | if (empty($entries)) { 78 | return false; 79 | } 80 | 81 | $container->set('entry', array_shift($entries)); 82 | $container->set('entries', $entries); 83 | 84 | return true; 85 | }, 86 | ); 87 | } 88 | 89 | private function processEntry(): WorkflowStep 90 | { 91 | return $this->setupStep( 92 | 'process-test', 93 | function (WorkflowControl $control, WorkflowContainer $container) { 94 | $control->attachStepInfo("Process entry " . $container->get('entry')); 95 | }, 96 | ); 97 | } 98 | 99 | public function failDataProvider(): array 100 | { 101 | return [ 102 | 'By Exception' => [function () { 103 | throw new InvalidArgumentException('Fail Message'); 104 | }], 105 | 'By failing step' => [fn (WorkflowControl $control) => $control->failStep('Fail Message')], 106 | 'By failing workflow' => [fn (WorkflowControl $control) => $control->failWorkflow('Fail Message')], 107 | ]; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /tests/WorkflowTest.php: -------------------------------------------------------------------------------- 1 | process($this->setupEmptyStep('process-test')) 26 | ->executeWorkflow(); 27 | 28 | $this->assertTrue($result->success()); 29 | $this->assertFalse($result->hasWarnings()); 30 | $this->assertEmpty($result->getWarnings()); 31 | $this->assertNull($result->getException()); 32 | $this->assertSame('test', $result->getWorkflowName()); 33 | 34 | $this->assertDebugLog( 35 | <<process($this->setupStep('process-test', function (WorkflowControl $control) { 52 | $control->attachStepInfo('Info 1'); 53 | $control->attachStepInfo('Info 2'); 54 | })) 55 | ->executeWorkflow(); 56 | 57 | $this->assertTrue($result->success()); 58 | $this->assertDebugLog( 59 | <<before($this->setupStep( 78 | 'before-test', 79 | function (WorkflowControl $control) use (&$exceptionLineNamespacedException) { 80 | $control->warning('before-warning', new FailWorkflowException('Fail-Message')); 81 | $exceptionLineNamespacedException = __LINE__ - 1; 82 | }, 83 | )) 84 | ->process($this->setupStep( 85 | 'process-test', 86 | function (WorkflowControl $control) use (&$exceptionLineGlobalException) { 87 | $control->warning('process-warning'); 88 | $control->warning('process-warning2', new InvalidArgumentException('Global-Exception-Message')); 89 | $exceptionLineGlobalException = __LINE__ - 1; 90 | }, 91 | )) 92 | ->executeWorkflow(); 93 | 94 | $file = __FILE__; 95 | 96 | $this->assertTrue($result->success()); 97 | $this->assertTrue($result->hasWarnings()); 98 | $this->assertSame($result->getWarnings(), [ 99 | WorkflowState::STAGE_BEFORE => [ 100 | "before-warning (FailWorkflowException: Fail-Message in $file::$exceptionLineNamespacedException)", 101 | ], 102 | WorkflowState::STAGE_PROCESS => [ 103 | 'process-warning', 104 | "process-warning2 (InvalidArgumentException: Global-Exception-Message in $file::$exceptionLineGlobalException)", 105 | ], 106 | ]); 107 | 108 | $this->assertDebugLog( 109 | <<process($this->setupStep('process-test', function () { 133 | throw new InvalidArgumentException('exception-message'); 134 | })) 135 | ->executeWorkflow(); 136 | 137 | $this->fail('Exception not thrown'); 138 | } catch (WorkflowException $exception) { 139 | $this->assertSame("Workflow 'test' failed", $exception->getMessage()); 140 | 141 | $result = $exception->getWorkflowResult(); 142 | $this->assertFalse($result->success()); 143 | 144 | $this->assertDebugLog( 145 | <<prepare($this->setupEmptyStep('prepare-test1')) 166 | ->prepare($this->setupStep('prepare-test2', $failingStep)) 167 | ->prepare($this->setupEmptyStep('prepare-test3')) 168 | ->process($this->setupEmptyStep('process-test')) 169 | ->executeWorkflow(null, false); 170 | 171 | $this->assertFalse($result->success()); 172 | $this->assertNotNull($result->getException()); 173 | $this->assertDebugLog( 174 | <<validate($this->setupEmptyStep('validate-test1'), true) 195 | ->validate($this->setupStep('validate-test2', $failingStep), true) 196 | ->validate($this->setupEmptyStep('validate-test3'), true) 197 | ->process($this->setupEmptyStep('process-test')) 198 | ->executeWorkflow(null, false); 199 | 200 | $this->assertFalse($result->success()); 201 | $this->assertNotNull($result->getException()); 202 | $this->assertDebugLog( 203 | <<validate($this->setupStep('validate-test1', $failingStep)) 224 | ->validate($this->setupEmptyStep('validate-test2')) 225 | ->validate($this->setupStep('validate-test3', $failingStep)) 226 | // hard validator must be executed first 227 | ->validate($this->setupEmptyStep('validate-test4'), true) 228 | ->process($this->setupEmptyStep('process-test')) 229 | ->executeWorkflow(null, false); 230 | 231 | $this->assertFalse($result->success()); 232 | $this->assertDebugLog( 233 | <<getException(); 249 | $this->assertInstanceOf(WorkflowValidationException::class, $exception); 250 | $this->assertCount(2, $exception->getValidationErrors()); 251 | 252 | foreach ($exception->getValidationErrors() as $validationError) { 253 | $this->assertSame('Fail Message', $validationError->getMessage()); 254 | } 255 | } 256 | 257 | 258 | /** 259 | * @dataProvider failDataProvider 260 | */ 261 | public function testFailingBeforeCancelsWorkflow(callable $failingStep): void 262 | { 263 | $result = (new Workflow('test')) 264 | ->before($this->setupEmptyStep('before-test1')) 265 | ->before($this->setupStep('before-test2', $failingStep)) 266 | ->before($this->setupEmptyStep('before-test3')) 267 | ->process($this->setupEmptyStep('process-test')) 268 | ->executeWorkflow(null, false); 269 | 270 | $this->assertFalse($result->success()); 271 | $this->assertNotNull($result->getException()); 272 | $this->assertDebugLog( 273 | <<before($this->setupEmptyStep('before-test')) 294 | ->process($this->setupStep('process-test', $failingStep)) 295 | ->process($this->setupEmptyStep('process-test2')) 296 | ->onSuccess($this->setupEmptyStep('success-test1')) 297 | ->onSuccess($this->setupEmptyStep('success-test2')) 298 | ->onError($this->setupEmptyStep('error-test1')) 299 | ->onError($this->setupEmptyStep('error-test2')) 300 | ->after($this->setupEmptyStep('after-test1')) 301 | ->after($this->setupEmptyStep('after-test2')) 302 | ->executeWorkflow(null, false); 303 | 304 | $this->assertFalse($result->success()); 305 | $this->assertDebugLog( 306 | <<before($this->setupEmptyStep('before-test')) 331 | ->process($this->setupEmptyStep('process-test1')) 332 | ->process($this->setupEmptyStep('process-test2')) 333 | ->onSuccess($this->setupEmptyStep('success-test1')) 334 | ->onSuccess($this->setupEmptyStep('success-test2')) 335 | ->onError($this->setupEmptyStep('error-test1')) 336 | ->onError($this->setupEmptyStep('error-test2')) 337 | ->after($this->setupEmptyStep('after-test1')) 338 | ->after($this->setupEmptyStep('after-test2')) 339 | ->executeWorkflow(null, false); 340 | 341 | $this->assertTrue($result->success()); 342 | $this->assertDebugLog( 343 | <<process($this->setupStep('process-test', $failingStep)) 372 | ->onError($this->setupStep('error-test1', $failingStep)) 373 | ->onError($this->setupEmptyStep('error-test2')) 374 | ->after($this->setupStep('after-test1', $failingStep)) 375 | ->after($this->setupEmptyStep('after-test2')) 376 | ->executeWorkflow(null, false); 377 | 378 | $this->assertFalse($result->success()); 379 | $this->assertTrue($result->hasWarnings()); 380 | $this->assertDebugLog( 381 | <<process($this->setupEmptyStep('process-test')) 410 | ->onSuccess($this->setupStep('success-test1', $failingStep)) 411 | ->onSuccess($this->setupEmptyStep('success-test2')) 412 | ->after($this->setupStep('after-test1', $failingStep)) 413 | ->after($this->setupEmptyStep('after-test2')) 414 | ->executeWorkflow(null, false); 415 | 416 | $this->assertTrue($result->success()); 417 | $this->assertTrue($result->hasWarnings()); 418 | $this->assertDebugLog( 419 | <<set('testdata', 'Hello World'); 444 | 445 | $result = (new Workflow('test')) 446 | ->before($this->setupStep( 447 | 'before-test', 448 | fn (WorkflowControl $control, WorkflowContainer $container) => 449 | $container->set('additional-data', 'Goodbye') 450 | )) 451 | ->process($this->setupStep( 452 | 'process-test', 453 | fn (WorkflowControl $control, WorkflowContainer $container) => 454 | $control->attachStepInfo($container->get('testdata')) 455 | )) 456 | ->after($this->setupStep( 457 | 'after-test', 458 | fn (WorkflowControl $control, WorkflowContainer $container) => 459 | $control->attachStepInfo($container->get('additional-data')) 460 | )) 461 | ->executeWorkflow($container); 462 | 463 | $this->assertTrue($result->success()); 464 | $this->assertDebugLog( 465 | <<validate($this->setupStep( 491 | 'validate-test1', 492 | fn (WorkflowControl $control) => $control->$skipFunction('skip-reason 1'), 493 | ), true) 494 | ->validate($this->setupEmptyStep('validate-test2')) 495 | ->validate($this->setupStep( 496 | 'validate-test3', 497 | fn (WorkflowControl $control) => $control->$skipFunction('skip-reason 2'), 498 | )) 499 | ->process($this->setupEmptyStep('process-test')) 500 | ->executeWorkflow(null, false); 501 | 502 | $this->assertTrue($result->success()); 503 | $this->assertDebugLog( 504 | << ['skipStep'], 525 | 'continue' => ['continue'], 526 | 'break' => ['break'], 527 | ]; 528 | } 529 | 530 | public function testSkipWorkflow(): void 531 | { 532 | $result = (new Workflow('test')) 533 | ->validate($this->setupStep( 534 | 'validate-test1', 535 | fn (WorkflowControl $control) => $control->skipStep('skip-reason 1'), 536 | ), true) 537 | ->validate($this->setupEmptyStep('validate-test2')) 538 | ->validate($this->setupStep( 539 | 'validate-test3', 540 | fn (WorkflowControl $control) => $control->skipWorkflow('skip-reason 2'), 541 | )) 542 | ->validate($this->setupEmptyStep('validate-test4')) 543 | ->process($this->setupEmptyStep('process-test')) 544 | ->executeWorkflow(null, false); 545 | 546 | $this->assertTrue($result->success()); 547 | $this->assertDebugLog( 548 | <<before($this->setupEmptyStep('before-test')) 567 | ->process($this->setupEmptyStep('process-test')) 568 | ->onSuccess($this->setupEmptyStep('success-test')) 569 | ->executeWorkflow(); 570 | 571 | $this->assertTrue($result->success()); 572 | 573 | $this->assertDebugLog( 574 | <<assertSame( 15 | $expected, 16 | preg_replace( 17 | [ 18 | '#[\w\\\\]+@anonymous[^)]+#', 19 | '/[\d.]+ms/', 20 | ], 21 | [ 22 | 'anonClass', 23 | '*', 24 | ], 25 | $result->debug($formatter), 26 | ), 27 | ); 28 | } 29 | 30 | protected function expectFailAtStep(string $step, WorkflowResult $result): void 31 | { 32 | $this->assertFalse($result->success(), 'Expected failing workflow'); 33 | $this->assertSame($step, get_class($result->getLastStep()), 'Workflow failed at a wrong step'); 34 | } 35 | 36 | protected function expectSkipAtStep(string $step, WorkflowResult $result): void 37 | { 38 | $this->assertTrue($result->success(), 'Expected successful workflow'); 39 | $this->assertSame($step, get_class($result->getLastStep()), 'Workflow skipped at a wrong step'); 40 | } 41 | } 42 | --------------------------------------------------------------------------------