├── .gitignore ├── .php-cs-fixer.php ├── LICENSE ├── README.md ├── composer.json ├── composer.lock ├── example.png ├── phpunit.xml ├── src ├── Pipable.php └── Pipeline.php └── tests ├── Fakes └── PipeForTesting.php ├── PipableTest.php ├── PipelineTest.php └── TestCase.php /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | # Project files 11 | .php_cs.cache 12 | .php-cs-fixer.cache 13 | .phpunit.result.cache 14 | coverage 15 | vendor 16 | 17 | # Editor directories and files 18 | .vscode/* 19 | !.vscode/extensions.json 20 | .idea 21 | .DS_Store 22 | *.code-workspace 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw? 28 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | exclude('tests')->in(__DIR__); 11 | 12 | return (new Config()) 13 | ->setFinder($finder) 14 | ->setRules([ 15 | '@PSR12' => true, 16 | 17 | // Arrays 18 | 'array_indentation' => true, 19 | 'array_push' => true, 20 | 'array_syntax' => ['syntax' => 'short'], 21 | 'list_syntax' => ['syntax' => 'short'], 22 | 'no_whitespace_before_comma_in_array' => true, 23 | 'trailing_comma_in_multiline' => ['elements' => ['arrays', 'arguments', 'parameters']], 24 | 25 | // Classes 26 | 'class_attributes_separation' => [ 27 | 'elements' => [ 28 | 'const' => 'one', 29 | 'method' => 'one', 30 | 'property' => 'one', 31 | ], 32 | ], 33 | 'new_with_braces' => true, 34 | 'no_blank_lines_after_class_opening' => true, 35 | 36 | // Operators 37 | 'assign_null_coalescing_to_coalesce_equal' => true, 38 | 'binary_operator_spaces' => ['default' => 'single_space'], 39 | 'logical_operators' => true, 40 | 'not_operator_with_successor_space' => true, 41 | 'object_operator_without_whitespace' => true, 42 | 43 | // Code fixes 44 | 'combine_consecutive_issets' => true, 45 | 'combine_consecutive_unsets' => true, 46 | 'explicit_string_variable' => true, 47 | 'implode_call' => true, 48 | 'lambda_not_used_import' => true, 49 | 'no_superfluous_elseif' => true, 50 | 'no_unused_imports' => true, 51 | 'no_useless_else' => true, 52 | 'return_assignment' => true, 53 | 'ternary_to_null_coalescing' => true, 54 | 55 | // Case transformations 56 | 'lowercase_static_reference' => true, 57 | 'magic_constant_casing' => true, 58 | 'magic_method_casing' => true, 59 | 'native_function_casing' => true, 60 | 61 | // Whitespace 62 | 'align_multiline_comment' => ['comment_type' => 'all_multiline'], 63 | 'blank_line_after_namespace' => true, 64 | 'blank_line_after_opening_tag' => true, 65 | 'blank_line_before_statement' => ['statements' => ['if', 'for', 'foreach', 'do', 'while', 'switch', 'try', 'return']], 66 | 'cast_spaces' => ['space' => 'single'], 67 | 'clean_namespace' => true, 68 | 'comment_to_phpdoc' => ['ignored_tags' => []], 69 | 'concat_space' => ['spacing' => 'one'], 70 | 'heredoc_indentation' => ['indentation' => 'same_as_start'], 71 | 'linebreak_after_opening_tag' => true, 72 | 'method_chaining_indentation' => true, 73 | 'multiline_whitespace_before_semicolons' => ['strategy' => 'no_multi_line'], 74 | 'no_extra_blank_lines' => [ 75 | 'tokens' => [ 76 | 'continue', 77 | 'curly_brace_block', 78 | 'extra', 79 | 'parenthesis_brace_block', 80 | 'return', 81 | 'square_brace_block', 82 | 'throw', 83 | 'use', 84 | 'switch', 85 | 'case', 86 | 'default', 87 | ], 88 | ], 89 | 'no_multiline_whitespace_around_double_arrow' => true, 90 | 'no_singleline_whitespace_before_semicolons' => true, 91 | 'no_spaces_around_offset' => ['positions' => ['inside', 'outside']], 92 | 'no_trailing_whitespace' => true, 93 | 'operator_linebreak' => ['position' => 'beginning'], 94 | 'simple_to_complex_string_variable' => true, 95 | 'single_blank_line_before_namespace' => true, 96 | 'types_spaces' => ['space' => 'single'], 97 | 'whitespace_after_comma_in_array' => true, 98 | ]); 99 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Sam Bedwell. 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 | # Pipelines, Supercharged! 2 | 3 |

Example code showcasing the Pipeline package using the with transaction method and the pipable trait

4 | 5 | ## Installation 6 | 7 | Install via composer: 8 | 9 | ```bash 10 | composer require chefhasteeth/pipeline 11 | ``` 12 | 13 | ## What are pipelines used for? 14 | 15 | A Pipeline allows you to pass data through a series of _pipes_ to perform a sequence of operations with the data. Each pipe is a callable piece of code: An invokable class, a closure, etc. Since each pipe operates on the data in isolation (the pipes don't know or care about each other), then that means you can easily compose complex workflows out of reusable actions that are also very easy to test—because they aren't interdependent. 16 | 17 | ## What makes it supercharged? 18 | 19 | If Laravel is the **batteries included** PHP Framework, then this package can be considered the batteries included version of the `Illuminate\Pipeline\Pipeline` class. It remains (mostly) compatible with Laravel's pipeline, but there are a few differences and added features. 20 | 21 | For example, do you see that `withTransaction()` method above? That will tell the Pipeline that we want to run this within a database transaction, which will automatically commit or roll back at the end, depending on whether the pipeline succeeded or failed. It also comes with a trait that gives you a `pipeThrough()` method to automatically send the object it's implemented on through a pipeline. 22 | 23 | ## What are the differences? 24 | 25 | In Laravel's `Pipeline` class, _pipes_ are essentially callables that receive two arguments: The `$passable`, which is the data passed to the pipe, and a `$next` callback that calls the next pipe. 26 | 27 | For the purposes of this package, I wanted my pipes to be used easily from anywhere, not just in the pipeline. (For example, this could take the form of a `GenerateThumbnail` action that appears as part of a pipeline, but also might appear in a cron job.) In other words, I don't want to have to pass an empty closure to the class or function to satisfy that `$next` argument. 28 | 29 | That's the biggest difference between this package and Laravel's `Pipeline`: The output of the current pipe is the input of the next pipe. 30 | 31 | ## Sending pipes down the pipeline 32 | 33 | When configuring the pipeline, you can send an array of class strings, invokable objects, closures, objects with a `handle()` method, or any other type that passes `is_callable()`. 34 | 35 | ```php 36 | use Chefhasteeth\Pipeline\Pipeline; 37 | 38 | class RegisterController 39 | { 40 | public function store(StoreRegistrationRequest $request) 41 | { 42 | return Pipeline::make() 43 | ->send($request->all()) 44 | ->through([ 45 | RegisterUser::class, 46 | AddMemberToTeam::class, 47 | SendWelcomeEmail::class, 48 | ]) 49 | ->then(fn ($data) => UserResource::make($data)); 50 | } 51 | } 52 | ``` 53 | 54 | Another approach you can take is to implement this as a trait on a data object. (You could even implement it on your `FormRequest` object if you really wanted.) 55 | 56 | ```php 57 | use Chefhasteeth\Pipeline\Pipable; 58 | 59 | class UserDataObject 60 | { 61 | use Pipable; 62 | 63 | public string $name; 64 | public string $email; 65 | public string $password; 66 | // ... 67 | } 68 | 69 | class RegisterController 70 | { 71 | public function store(StoreRegistrationRequest $request) 72 | { 73 | return UserDataObject::fromRequest($request) 74 | ->pipeThrough([ 75 | RegisterUser::class, 76 | AddMemberToTeam::class, 77 | SendWelcomeEmail::class, 78 | ]) 79 | ->then(fn ($data) => UserResource::make($data)); 80 | } 81 | } 82 | ``` 83 | 84 | To maintain compatibility with Laravel's `Pipeline` class, the `through()` method can accept either a single array of callables or multiple parameters, where each parameter is one of the callable types listed previously. However, the `pipeThrough()` trait method only accepts an array, since it also has a second optional parameter. 85 | 86 | ## Using database transactions 87 | 88 | When you want to use database transactions in your pipeline, the method will be different depending on if you're using the trait or the `Pipeline` class. 89 | 90 | Using the `Pipeline` class: 91 | 92 | ```php 93 | Pipeline::make()->withTransaction() 94 | ``` 95 | 96 | The `withTransaction()` method will tell the pipeline to use transactions. When you call the `then()` or `thenReturn()` methods, a database transaction will begin before executing the pipes. If an exception is encountered during the pipeline, the transaction will be rolled back so no data is committed to the database. Assuming the pipeline completed successfully, the transaction is committed. 97 | 98 | When using the trait, you can pass a second parameter to the `pipeThrough()` method: 99 | 100 | ```php 101 | $object->pipeThrough($pipes, withTransaction: true); 102 | ``` 103 | 104 | ## Testing 105 | 106 | ```bash 107 | composer test 108 | ``` 109 | 110 | ## License 111 | 112 | The MIT License (MIT). Please see [LICENSE](LICENSE) for more information. 113 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chefhasteeth/pipeline", 3 | "type": "library", 4 | "license": "MIT", 5 | "keywords": [ 6 | "laravel", 7 | "pipeline", 8 | "pipe" 9 | ], 10 | "autoload": { 11 | "psr-4": { 12 | "Chefhasteeth\\Pipeline\\": "src/" 13 | } 14 | }, 15 | "autoload-dev": { 16 | "psr-4": { 17 | "Chefhasteeth\\Pipeline\\Tests\\": "tests" 18 | } 19 | }, 20 | "authors": [ 21 | { 22 | "name": "Sam Bedwell", 23 | "email": "hello@chefhasteeth.com" 24 | } 25 | ], 26 | "require": { 27 | "php": "^8.1.0", 28 | "laravel/framework": "^9.0|^10.0|^11.0|^12.0" 29 | }, 30 | "require-dev": { 31 | "friendsofphp/php-cs-fixer": "^3.8", 32 | "nunomaduro/collision": "^6.2|^8.0", 33 | "orchestra/testbench": "^7.5|^9.0|^10.0", 34 | "phpunit/phpunit": "^9.5|^10.5|^11.5.3", 35 | "squizlabs/php_codesniffer": "^3.6" 36 | }, 37 | "scripts": { 38 | "test": "vendor/bin/phpunit" 39 | }, 40 | "minimum-stability": "dev", 41 | "prefer-stable": true, 42 | "config": { 43 | "sort-packages": true 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chefhasteeth/pipeline/34175f5df4102c6d78457e6d05b8d43181f2dfea/example.png -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 16 | 17 | 18 | src 19 | 20 | 21 | 22 | 23 | tests 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/Pipable.php: -------------------------------------------------------------------------------- 1 | withTransaction(); 13 | } 14 | 15 | return $pipeline->send($this)->through($pipes); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Pipeline.php: -------------------------------------------------------------------------------- 1 | useTransaction = true; 27 | 28 | return $this; 29 | } 30 | 31 | /** 32 | * Set the object being sent through the pipeline. 33 | */ 34 | public function send(mixed $passable): static 35 | { 36 | $this->passable = $passable; 37 | 38 | return $this; 39 | } 40 | 41 | /** 42 | * Set the array of pipes. 43 | * 44 | * @param array|mixed $pipes 45 | */ 46 | public function through($pipes): static 47 | { 48 | $this->pipes = is_array($pipes) ? $pipes : func_get_args(); 49 | 50 | return $this; 51 | } 52 | 53 | /** 54 | * Run the pipeline with a final callback. 55 | */ 56 | public function then(Closure $step): mixed 57 | { 58 | return $step($this->traversePipeline()); 59 | } 60 | 61 | /** 62 | * Run the pipeline and return the result. 63 | */ 64 | public function thenReturn(): mixed 65 | { 66 | return $this->then(fn ($passable) => $passable); 67 | } 68 | 69 | protected function pipes() 70 | { 71 | return [ 72 | ...$this->pipes, 73 | function ($passable) { 74 | $this->commitTransaction(); 75 | 76 | return $passable; 77 | }, 78 | ]; 79 | } 80 | 81 | protected function traversePipeline() 82 | { 83 | try { 84 | $this->startTransaction(); 85 | 86 | return array_reduce($this->pipes(), $this->executePipe(...), $this->passable); 87 | } catch (Throwable $e) { 88 | $this->undoTransaction(); 89 | 90 | throw $e; 91 | } 92 | } 93 | 94 | protected function executePipe($previousValue, $pipe) 95 | { 96 | $action = $pipe; 97 | 98 | if (is_string($pipe) && class_exists($pipe)) { 99 | $action = resolve($pipe); 100 | } 101 | 102 | if (is_callable($action)) { 103 | return $action($previousValue); 104 | } 105 | 106 | if (method_exists($action, 'handle')) { 107 | return $action->handle($previousValue); 108 | } 109 | 110 | throw new UnexpectedValueException( 111 | 'Pipeline only accepts callables and class strings', 112 | ); 113 | } 114 | 115 | protected function startTransaction(): void 116 | { 117 | if (! $this->useTransaction) { 118 | return; 119 | } 120 | 121 | DB::beginTransaction(); 122 | } 123 | 124 | protected function commitTransaction(): void 125 | { 126 | if (! $this->useTransaction) { 127 | return; 128 | } 129 | 130 | DB::commit(); 131 | } 132 | 133 | protected function undoTransaction(): void 134 | { 135 | if (! $this->useTransaction) { 136 | return; 137 | } 138 | 139 | DB::rollBack(); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /tests/Fakes/PipeForTesting.php: -------------------------------------------------------------------------------- 1 | pipeline() 25 | ->pipeThrough( 26 | function ($data) { 27 | $this->assertSame('one', $data->one); 28 | $this->assertSame('two', $data->two); 29 | }, 30 | ) 31 | ->thenReturn(); 32 | } 33 | 34 | /** @test */ 35 | public function trait_sends_self_through_pipeline_with_transaction() 36 | { 37 | DB::spy(); 38 | 39 | $this->pipeline() 40 | ->pipeThrough( 41 | function ($data) { 42 | $this->assertSame('one', $data->one); 43 | $this->assertSame('two', $data->two); 44 | }, 45 | withTransaction: true, 46 | ) 47 | ->thenReturn(); 48 | 49 | DB::shouldHaveReceived('beginTransaction')->once(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/PipelineTest.php: -------------------------------------------------------------------------------- 1 | send(0) 17 | ->through( 18 | fn ($data) => ++$data, 19 | fn ($data) => ++$data, 20 | ) 21 | ->thenReturn(); 22 | 23 | $this->assertSame(2, $result); 24 | } 25 | 26 | /** @test */ 27 | public function throws_exception_from_pipeline() 28 | { 29 | $this->assertThrows( 30 | function () { 31 | Pipeline::make() 32 | ->send('test') 33 | ->through(fn () => throw new UnexpectedValueException()) 34 | ->thenReturn(); 35 | }, 36 | UnexpectedValueException::class, 37 | ); 38 | } 39 | 40 | /** @test */ 41 | public function throws_exception_with_invalid_pipe_type() 42 | { 43 | $this->assertThrows( 44 | function () { 45 | Pipeline::make() 46 | ->send('test') 47 | ->through('not a callable or class string') 48 | ->thenReturn(); 49 | }, 50 | UnexpectedValueException::class, 51 | ); 52 | } 53 | 54 | /** @test */ 55 | public function accepts_class_strings_as_pipes() 56 | { 57 | $result = Pipeline::make() 58 | ->send('test data') 59 | ->through(PipeForTesting::class) 60 | ->thenReturn(); 61 | 62 | $this->assertSame('test data', $result); 63 | } 64 | 65 | /** @test */ 66 | public function successfully_completes_a_database_transaction() 67 | { 68 | $database = DB::spy(); 69 | 70 | Pipeline::make() 71 | ->withTransaction() 72 | ->send('test') 73 | ->through(fn ($data) => $data) 74 | ->thenReturn(); 75 | 76 | $database->shouldHaveReceived('beginTransaction')->once(); 77 | $database->shouldHaveReceived('commit')->once(); 78 | } 79 | 80 | /** @test */ 81 | public function rolls_the_databsae_transaction_back_on_failure() 82 | { 83 | $database = DB::spy(); 84 | 85 | rescue( 86 | fn () => Pipeline::make() 87 | ->withTransaction() 88 | ->send('test') 89 | ->through(fn () => throw new UnexpectedValueException()) 90 | ->thenReturn(), 91 | ); 92 | 93 | $database->shouldHaveReceived('beginTransaction')->once(); 94 | $database->shouldHaveReceived('rollBack')->once(); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 |