🛁 PHP Pipes
I really like unix pipes, they are easy to intuit. Data 'flows' from one process to another, the same concept has been added to a number of programming languages over the years but not PHP.
There have been attempts to add it to the language (v1 & v2) but nothing has been implemented yet. There are userland implementations but they are pretty unwieldy IMO.
Pipe v3 RFC - Since I began drafting this post there has been a third RFC which looks like it's going to pass!
As it seems to be my thing lately, I wanted to see if I could make a small stupid simple native implementation.
Attempt 1
function pipe(mixed $value): object { return new class ($value) { private array $callbacks = []; public function __construct(protected mixed $value) { } public function through(callable $callback): self { $this->callbacks[] = $callback; return $this; } public function go(): mixed { return array_reduce( $this->callbacks, fn (mixed $value, callable $callback): mixed => $callback($value), $this->value, ); } }; } $newValue = pipe('Something') ->through(fn (string $value): string => substr($value, 2)) ->through(fn (string $value): string => strtoupper($value)) ->go();
This worked, but I didn't like the call to go()
at the end. It could be changed so that each closure is executed
as through()
is called, but I abandoned this as the whole thing was a bit meh and I thought I could do better.
Attempt 2
I ditched the class entirely and switched to a single variadic function.
function pipe(mixed $value, callable ...$callables): mixed { return array_reduce( $callables, fn (mixed $value, callable $callback): mixed => $callback($value), $value, ); } $newValue = pipe( 'Something', fn (string $value): string => substr($value, 2), fn (string $value): string => strtoupper($value), );
If you fancy a quick round of code golf, you can use a short closure and ditch the mixed type:
$pipe = fn ($value, callable ...$fns) => array_reduce($fns, fn ($value, callable $fn) => $fn($value), $value);
Array Manipulation
I tested out the pipe()
function in my RSS reader project, it was
great except for this unreadable mess:
$feedUrls = pipe( $feeds, fn (array $feeds): array => array_filter($feeds, fn (string $feedUrl): bool => str_starts_with($feedUrl, '#') === false), fn (array $feeds): array => array_map(fn (string $feedUrl): string => $feedUrl[0] === '*' ? substr($feedUrl, 1):$feedUrl, $feeds), );
It could be easier to read if the nested short closures where expanded into anonymous functions, but I really wanted to keep one operation per line if at all possible.
The root of the problem is that the array manipulation functions require a closure. I needed to mark the operations as
'array-acting'. What I came up with was to use named parameters. How does this help? The name can be used to add
The example from above now becomes:
$feedUrls = pipe( $feeds, filterOutCommenttedUrls: fn (string $feedUrl): bool => str_starts_with($feedUrl, '#'), mapToRemoveLeadingStar: fn (string $feedUrl): string => ltrim($feedUrl, '*'), );
function pipe(mixed $value, callable ...$operations): mixed { foreach ($operations as $name => $callback) { $callback = match (true) { is_int($name) => $callback, str_starts_with($name, 'filter') => fn () => array_filter($value, $callback), str_starts_with($name, 'map') => fn () => array_map($callback, $value), str_starts_with($name, 'sortBy') => function () use (&$value, $callback): array { usort($value, $callback); return $value; }, default => $callback, }; $value = $callback($value); } return $value; }
This continues to work for non-array values, and I quite like that it adds a pseudo comment to each operation which adds to the readability.
$newValue = pipe( 'Something', reduceStringLength: fn (string $value): string => substr($value, 2), transformToUppercase: fn (string $value): string => strtoupper($value), );
The thing I don't like about this is that it requires prior knowledge to understand that the filter, map and sortBy prefixes are special. I also feel like this is going against the intended behaviour for named arguments, aka it's a hack.
Limitations
There are a couple of limitations which I have come across:
- Argument names can't be re-used
- Static analysis can't understand the magic
- Named arguments can't be followed by unnamed ones. This is not allowed:
$newValue = pipe( 'Something', transformToUppercase: strtoupper(...), // named argument trim(...), // ❌ unnamed argument );
Conditional Operations
If you need to conditionally add/remove operations to the pipe, you must build an array and then spread them:
$fns = [ 'filterOutComments' => fn (string $line): bool => str_starts_with($line, '#') === false, ]; if ($someLogic) { $fns['mapToRemoveLeadingStar'] = fn (string $feedUrl): string => ltrim($feedUrl, '*'), } $feedUrlsToFetch = pipe($feedUrls, ...$fns);