├── .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 | [](https://packagist.org/packages/wol-soft/php-workflow)
2 | [](https://php.net/)
3 | [](https://codeclimate.com/github/wol-soft/php-workflow/maintainability)
4 | [](https://github.com/wol-soft/php-workflow/actions/workflows/main.yml)
5 | [](https://coveralls.io/github/wol-soft/php-workflow)
6 | [](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 |
--------------------------------------------------------------------------------