Simulating the Pipe Operator in PHP: Building Function Chains
AI generated
<?php
8.4
PHP · Functional Programming · Function Composition
Simulating the Pipe Operator in PHP
Function Chains Without a Native Operator

Unlike Elixir or the proposed JavaScript feature, PHP has no native pipe operator. A single small pipe() function still recreates the same readable data flow in PHP 8.4, left to right instead of deeply nested function calls.

17 min read pipe() · compose() · function composition PHP 8.2 · 8.3 · 8.4

1. Why PHP Has No Native Pipe Operator

A pipe operator, such as Elixir's |> or the long-discussed JavaScript proposal, forwards the return value of an expression directly as the input to the next expression. PHP does not have this language feature, even though it has been discussed as an RFC more than once. Without a native operator, the only option left is recreating the same effect with an ordinary function that applies a list of functions one after another to a starting value.

This pipe operator simulation is not a compromised substitute, it is nearly equivalent in practice: a pipe() function with a variable number of arguments takes on exactly the same task as a native operator, just with a few more characters per call. The key advantage over deeply nested function calls remains intact: the reading direction follows the actual data flow, from the first to the last transformation.

This article shows the full build-out of a pipe operator simulation in PHP 8.4, from a simple pipe() function through compose() to error handling and typing with PHPStan.

2. The Problem with Nested Function Calls

Without a pipe operator, multi-step transformations in PHP are frequently written as nested function calls: f(g(h($value))). The order in which the functions actually execute runs inside out, while the reading direction of the code runs outside in. Beyond three nested calls, this mismatch between execution order and reading order becomes a real source of errors when trying to follow the code.

The alternative, a chain of intermediate variables, solves the reading-direction problem but introduces new, usually poorly named variables like $step1, $step2 that needlessly clutter the function's namespace. A pipe operator simulation solves both problems at once: the reading direction follows the data flow, and no superfluous intermediate variables appear in the surrounding scope.


<?php

declare(strict_types=1);

function slugify(string $s): string
{
    return strtolower(trim($s));
}

function replaceSpaces(string $s): string
{
    return str_replace(' ', '-', $s);
}

function removeSpecialChars(string $s): string
{
    return preg_replace('/[^a-z0-9\-]/', '', $s);
}

// Nested calls — execution order is inside-out, reading order is outside-in
$slugNested = removeSpecialChars(replaceSpaces(slugify('  Hello World! ')));

// Intermediate variables — readable order, but clutters the scope
$step1 = slugify('  Hello World! ');
$step2 = replaceSpaces($step1);
$slugSteps = removeSpecialChars($step2);

3. Implementing a Custom pipe() Function

The core idea of a pipe operator simulation is a function that accepts a starting value plus a variable number of further functions and applies those functions one after another, with each function receiving the previous one's result as its input. array_reduce makes this possible in a single, compact implementation without writing a loop by hand.

What matters for a robust pipe operator simulation is using the first-class-callable syntax from PHP 8.1 as the call form: slugify(...) instead of the string 'slugify', so the IDE and PHPStan can statically check every stage of the pipeline. Equally important is parameter order: a pipe() function reads most naturally with the starting value first, followed by the transformation functions in execution order.


<?php

declare(strict_types=1);

/**
 * Applies a sequence of single-argument functions left to right.
 *
 * @param mixed $initial
 * @param callable ...$fns
 * @return mixed
 */
function pipe(mixed $initial, callable ...$fns): mixed
{
    return array_reduce(
        $fns,
        fn (mixed $carry, callable $fn): mixed => $fn($carry),
        $initial,
    );
}

// Reading order now matches execution order, left to right
$slug = pipe(
    '  Hello World! ',
    slugify(...),
    replaceSpaces(...),
    removeSpecialChars(...),
);

echo $slug; // hello-world

4. compose(): Building Function Chains Without an Intermediate Value

While pipe() immediately accepts a starting value and returns the final result, compose() instead produces a new, reusable function without evaluating anything itself. This new function can be called any number of times with different inputs, which is what sets compose() apart from the one-off evaluation of pipe(). The difference is comparable to the one between an immediately executed expression and a named function.

Mathematically, compose(f, g, h) corresponds to the function x => h(g(f(x))), where the order in the pipe operator simulation is deliberately chosen left to right, contrary to the mathematical convention that usually reads right to left. This decision follows the more intuitive reading direction of the pipe operator and should be documented consistently across the team to avoid confusion with the mathematical notation.


<?php

declare(strict_types=1);

/**
 * Builds a reusable function from a sequence of single-argument functions.
 *
 * @param callable ...$fns
 * @return Closure(mixed): mixed
 */
function compose(callable ...$fns): Closure
{
    return function (mixed $initial) use ($fns): mixed {
        return pipe($initial, ...$fns);
    };
}

// Build once, call the resulting closure many times
$slugify = compose(
    slugify(...),
    replaceSpaces(...),
    removeSpecialChars(...),
);

echo $slugify('  Hello World! ');   // hello-world
echo $slugify('  Another Title ');  // another-title

5. Practical Example: Data Processing as a Pipeline

A common use case for a pipe operator simulation is processing CSV rows or API responses in several clearly separated steps: parsing, normalizing, validating, transforming. Every step remains a small, independently testable function, while pipe() takes care of applying them in order. This makes every individual processing step verifiable on its own via unit tests, without having to set up the entire pipeline.

The advantage over one large function with all steps inline: every stage of the pipe operator simulation can be swapped, reused, or recombined in a different order independently. An import pipeline for product data and an export pipeline for reports can then share the same normalization steps without duplicating code.


<?php

declare(strict_types=1);

/**
 * @param array<string, string> $row
 * @return array<string, string>
 */
function trimAllValues(array $row): array
{
    return array_map(trim(...), $row);
}

/**
 * @param array<string, string> $row
 * @return array{sku: string, price: float, name: string}
 */
function normalizeRow(array $row): array
{
    return [
        'sku' => strtoupper($row['sku']),
        'price' => (float) str_replace(',', '.', $row['price']),
        'name' => ucfirst($row['name']),
    ];
}

/**
 * @param array{sku: string, price: float, name: string} $row
 * @return array{sku: string, price: float, name: string}
 */
function applyMinimumPrice(array $row): array
{
    $row['price'] = max($row['price'], 0.01);
    return $row;
}

$rawRow = ['sku' => ' sku-123 ', 'price' => '19,90', 'name' => ' widget '];

$processedRow = pipe(
    $rawRow,
    trimAllValues(...),
    normalizeRow(...),
    applyMinimumPrice(...),
);

// ['sku' => 'SKU-123', 'price' => 19.9, 'name' => 'Widget']

6. Error Handling in Pipelines: Bailing Out Early

A plain pipe() call does not automatically stop when an intermediate stage produces an invalid state, for example null after a failed parsing step. The next function in the chain then receives that invalid value and may fail with an unclear error message, far removed from the actual root cause. For robust pipelines, it is worth using a variant that checks for null after every stage and short-circuits the chain as soon as needed.

This defensive pipe operator simulation can be built elegantly with the nullsafe operator or a custom pipeOrNull() variant that skips every stage as soon as the value has become null. The next article in this series shows how Maybe and Either types solve the same task even more explicitly, by making the error state part of the return type instead of implicitly carrying it through null.


<?php

declare(strict_types=1);

/**
 * Pipe variant that short-circuits as soon as a stage returns null.
 *
 * @param mixed $initial
 * @param callable ...$fns
 * @return mixed
 */
function pipeOrNull(mixed $initial, callable ...$fns): mixed
{
    $value = $initial;

    foreach ($fns as $fn) {
        if ($value === null) {
            return null;
        }

        $value = $fn($value);
    }

    return $value;
}

function parseInteger(string $s): ?int
{
    return ctype_digit($s) ? (int) $s : null;
}

function doubleIt(int $n): int
{
    return $n * 2;
}

echo pipeOrNull('42', parseInteger(...), doubleIt(...));  // 84
var_dump(pipeOrNull('abc', parseInteger(...), doubleIt(...))); // NULL, doubleIt never runs

7. Typing with PHPStan: Templates for Generic Pipelines

A generic pipe() function with mixed types does not let PHPStan check whether the output of one stage matches the expected input type of the next stage. For small, fixed pipelines it is therefore worth writing a specialized, typed variant with concrete parameter types instead of mixed, for example pipeStringToInt(string $s, callable $a, callable $b): int. This variant loses generality but gains full static checkability.

For generic pipelines with a variable number of stages, PHPStan offers @template types that at least propagate the input type of the first stage and the return type of the last stage correctly, even though the intermediate stages can no longer be fully tracked by PHPStan at that point. In practice this is usually enough to catch the most common typos in function names and obvious type mismatches at the beginning and end of the chain.


<?php

declare(strict_types=1);

/**
 * Two-stage typed pipe for maximum static analysis precision.
 *
 * @template TInput
 * @template TMiddle
 * @template TOutput
 * @param TInput $initial
 * @param Closure(TInput): TMiddle $first
 * @param Closure(TMiddle): TOutput $second
 * @return TOutput
 */
function pipe2(mixed $initial, Closure $first, Closure $second): mixed
{
    return $second($first($initial));
}

$result = pipe2(
    '  Hello World! ',
    slugify(...),
    strlen(...),
);
// PHPStan infers $result as int

8. Pipe Operator vs. Fluent Interfaces and Method Chains

Fluent interfaces, where every method returns $this and method calls get chained, solve a similar readability problem as a pipe operator simulation, but are tied to a specific class. A pipe() function, on the other hand, works with arbitrary, independent functions from different namespaces and needs no shared base class or consistent interface.

The downside of fluent interfaces: they require every method to belong to the same class, which leads to unnecessary wrapper classes when combining functions from different libraries. A pipe operator simulation with free functions is more flexible here, because arbitrary callable values can be combined regardless of whether they come from a class, a global function, or an anonymous closure.

9. Pipe Operator Simulation Compared Directly

The following table contrasts the different approaches for multi-step transformations in PHP and shows when a pipe operator simulation is the most readable solution.

Approach Reading Order Intermediate Variables Assessment
Nested calls Inside out None Hard to read beyond 3 levels
Intermediate variables Top to bottom Many, often poorly named Readable, but cluttered
pipe() function Left to right None Clear, follows the data flow
Fluent interface Left to right None Only within one class
compose() function Left to right None Produces a reusable function

For one-off transformations, pipe() is the most direct solution, while compose() pays off for chains that are needed repeatedly, building the chain once and calling it any number of times afterward without restating the function list.

Mironsoft

PHP architecture, code reviews and modern language features in everyday team work

Untangling deeply nested function calls?

We show where pipe() and compose() functions can replace deeply nested transformations in your PHP code with clear, testable pipelines.

Code Review

Identifying nested calls and checking for pipeline-suitable structure

Refactoring

Splitting data processing logic into clearly separated, testable pipeline stages

Training

Introducing function composition hands-on, including PHPStan typing

10. Summary

A pipe operator simulation in PHP replaces deeply nested function calls and unnecessary intermediate variables with a readable, left-to-right flowing chain of transformations. The custom pipe() function evaluates a starting value immediately, while compose() produces a reusable function for repeated application. Both building blocks rest on array_reduce and the first-class-callable syntax and need no external library.

For robustness in real pipelines, a variant like pipeOrNull() pays off, bailing out early on invalid intermediate values instead of letting errors propagate uncontrolled. With PHPStan templates, at least the input and output type of the whole chain stays checked, even though intermediate stages cannot be fully typed in generic pipelines. For most projects, a single shared pipe() function in a central utility file is entirely sufficient.

Simulating the Pipe Operator in PHP — The Key Points at a Glance

pipe()

Accepts a starting value and several functions, evaluates the chain immediately, based on array_reduce.

compose()

Produces a reusable closure from several functions, evaluates only when actually called.

Error Handling

pipeOrNull() bails out immediately on null intermediate values instead of propagating errors uncontrolled.

Typing

Type small, fixed pipelines with concrete types, generic pipelines with PHPStan templates.

11. FAQ: Simulating the Pipe Operator in PHP

1Does PHP have a native pipe operator?
No native feature. Recreated with a small pipe() function built on array_reduce.
2pipe() vs. compose()?
pipe() evaluates a starting value immediately. compose() produces a reusable function for later, repeated calls.
3Why is reading direction a problem?
Nested calls execute inside out, but are read outside in. Beyond three levels this becomes hard to follow.
4Simplest implementation?
With array_reduce: accept a starting value and function array, apply each function to the intermediate result.
5Which callable syntax to use?
First-class-callable syntax like slugify(...) instead of string callables, for full PHPStan checking.
6How to handle failed stages?
With pipeOrNull(), which checks for null after every stage and bails out of the chain immediately when needed.
7Fully type-safe with PHPStan?
Yes for a fixed, small number of stages. Generic pipelines can only check intermediate types to a limited extent.
8Difference from fluent interfaces?
Fluent interfaces are tied to a class. pipe() combines arbitrary, independent functions without a base class.
9Is an external library needed?
Not for most projects. A custom pipe() and compose() function fully covers the most common cases.
10Order of application in compose()?
Left to right, contrary to the mathematical convention, to follow the intuitive pipe reading direction.