Introducing Piper: array and string manipulation with the pipe operator
The pipe operator was a great addition in PHP 8.5. It brings the ergonomics of wrapper objects with the composability of standalone functions. Unfortunately, it doesn't have any widespread use yet. The standard array & string manipulation functions in PHP aren't exactly known for their API consistency, which makes the pipe operator awkward to use.
Piper is an attempt to wrap the standard library to make it compatible with the pipe operator.
It comes with array helpers:
use function Spatie\Piper\Arr\{filter, map};
$popular = $posts
|> filter(fn (Post $post) => $post->views > 1000)
|> map(fn (Post $post) => $post->title);
// ["Claude Talk Small. Code Still Big.", …]
And string helpers:
use function Spatie\Piper\Str\{lower, replace};
'Hello, world!'
|> lower()
|> replace('world', 'Piper')
// "hello, Piper!"
Since all functions work with primitives, you can mix and match:
use function Spatie\Piper\Arr\{filter, join, map, values};
use function Spatie\Piper\Str\{prefix, suffix};
[1, 2, 3, 4, 5, 6]
|> filter(fn (int $i) => $i % 2 === 0)
|> map(fn (int $i) => pow($i, 2))
|> values()
|> join(', ', ', and ')
|> prefix('The winning numbers are ')
|> suffix('.');
// "The winning numbers are 4, 16, and 36."
Piper is available on GitHub and can be installed using Composer.
composer require spatie/piper
Why the pipe operator is the best of all worlds
The pipe operator exists to provide a clean syntax for sequential code.
Without the pipe operator, we assign a bunch of intermediate variables when we want to perform multiple operations on a value.
$orders = [
['id' => 1, 'status' => 'paid', 'total' => 120],
['id' => 2, 'status' => 'pending', 'total' => 80],
['id' => 3, 'status' => 'paid', 'total' => 45],
['id' => 4, 'status' => 'paid', 'total' => 200],
];
$largePaidOrders = array_filter(
$orders,
fn (array $order) =>
$order['status'] === 'paid' && $order['total'] > 100
);
$report = array_map(fn (array $order) => [
'id' => $order['id'],
'amount' => '$' . number_format($order['total'], 2),
], $largePaidOrders);
If we want to get rid of the intermediate variables, we can directly pass the result of the previous function to the next. But then our code is written in the opposite order of its execution.
$report = array_map(
fn (array $order) => [
'id' => $order['id'],
'amount' => '$' . number_format($order['total'], 2),
],
array_filter($orders, fn (array $order) =>
$order['status'] === 'paid' && $order['total'] > 100
)
);
At a glance, this makes it very difficult to spot where this statement starts. (It's the $orders variable in the array_filter call.)
Vanilla PHP makes you choose between arbitrarily named intermediate variables (ugh) or inside-out nested code (ugh).
Alternatively, we can wrap our array in an object with a fluent API, like Laravel's excellent Collection object.
$report = collect($orders)
->filter(fn (array $order) =>
$order['status'] === 'paid' && $order['total'] > 100
)
->map(fn (array $order) => [
'id' => $order['id'],
'amount' => '$' . number_format($order['total'], 2),
])
->all();
But wrapper objects come with their own set of problems:
- You need to unwrap them to use them somewhere else; the
->all()call above. - They're not extendable. If I want a method for something domain-specific, I need to create a custom collection object. Since my array now lives in a custom collection object, I also lose composability because my code might be spread across multiple collection objects.
Enter the pipe operator |>. With the pipe operator, the result of the previous statement is passed to the next function in the pipeline. This allows us to keep using regular, composable functions without wrapping the array in a custom object while keeping the order of the code consistent with the order of execution.
$report = $orders
|> (fn (array $orders) => array_filter($orders, fn (array $order) =>
$order['status'] === 'paid' && $order['total'] > 100
))
|> (fn (array $orders) => array_map(fn (array $order) => [
'id' => $order['id'],
'amount' => '$' . number_format($order['total'], 2),
], $orders));
The pipe operator in its current form has one constraint: the function we're piping to must accept exactly one argument. If not, you can wrap it in a single-argument closure to make it compatible.
Here's an example that returns all even numbers in an array:
[1, 2, 3, 4]
|> (fn (array $numbers) => array_filter($numbers, fn (int $i) => $i % 2 === 0));
In addition, PHP carries a bunch of weight from the past and its standard library is full of inconsistencies in argument order. So while we have the pipe operator in the language, it can't always flourish.
Piper: the pipe operator-first utility library
Piper is a port of Laravel's collection & string utility methods, optimized for the pipe operator.
use function Spatie\Piper\Arr\filter;
[1, 2, 3, 4]
|> filter(fn (int $i) => $i % 2 === 0);
And back to our $report example:
use function Spatie\Piper\Arr\{filter, map};
$report = $orders
|> filter(fn (array $order) =>
$order['status'] === 'paid' && $order['total'] > 100
)
|> map(fn (array $order) => [
'id' => $order['id'],
'amount' => '$' . number_format($order['total'], 2),
]);
This checks all the boxes:
- No intermediate variables
- Code aligns with the execution order
Bliss!
What does optimized for the pipe operator mean? Each function is a higher-order function that returns a function that has exactly one subject argument.
This is what Piper's filter function looks like:
function filter(?callable $callback = null): Closure
{
return function (array $items) use ($callback): array {
return $callback === null
? array_filter($items)
: array_filter($items, $callback, ARRAY_FILTER_USE_BOTH);
};
}
Without the pipe operator, you'd call our even numbers example like this:
filter(fn (int $i) => $i % 2 === 0)([1, 2, 3, 4]);
// [1 => 2, 3 => 4]
The arguments passed to the outer function are used to generate a new function that accepts the input. This is called partial application, which is getting some extra love in PHP 8.6!
Piper supports the majority of collection & string functions that ship with Laravel. A full list can be referenced in the README.
Looking forward
I might never use this library. I might never use the pipe operator at all. Agentic coding is pushing syntax to a standstill. New programming APIs that bring benefits to execution will be pushed forward. But the whole fight for making code look good seems to have been extinguished as AI is slowly moving us to focus on outcomes more than line-by-line code cleanliness. That's not necessarily a bad thing, but part of our craft might get lost along the way.
Ironically, I convinced myself to actually write this because I have AI at my disposal. Manually porting 234 functions and a full test suite is not something I was looking forward to. Instead, the time spent building and refining this package is measurable in hours.
Either way, this is a fun experiment to conduct. And I look forward to tinkering further and giving it a shot in some real projects. If it sticks, spatie/laravel-piper will be next in line with a better Laravel integration (piping collections or anything Arrayable without casting to an array first, LazyCollection support, etc.)