├── .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 |

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 |