Functional Programming in PHP: Practical Techniques

Philip Rehberger Sep 14, 2026 7 min read

Use immutability, pure functions, and pipelines in PHP without changing language.

PHP is not Haskell. The language has no formal functional features — no lazy evaluation, no algebraic data types, no proper tail calls. But the functional programming style — immutability, pure functions, pipelines — is still useful in PHP, and the language has acquired enough syntax that it is pleasant to write.

This post is the practical functional techniques that show up in modern PHP codebases: when they help, when they hurt, and what the language actually supports.

Pure Functions

A pure function depends only on its inputs and produces only its return value. No mutation, no I/O, no global state.

// Impure — reads from globals, mutates state
function calculateTotal(): int
{
    global $cart;
    $cart->total = array_sum(array_map(fn ($i) => $i->price, $cart->items));
    return $cart->total;
}

// Pure — explicit inputs, no side effects
function calculateTotal(array $items): int
{
    return array_sum(array_map(fn ($i) => $i->price, $items));
}

The pure version is easier to test, easier to compose, and easier to reason about. The cost is that the caller has to pass the data explicitly.

Pure functions show up most naturally in calculation-heavy code: pricing, scoring, validation. Anywhere the output is a function of the input.

Immutability With readonly

PHP 8.1 added readonly properties. PHP 8.2 added readonly classes. Together they enable immutable value objects without manual copy-on-write code.

final readonly class Money
{
    public function __construct(
        public int $cents,
        public string $currency,
    ) {}

    public function add(Money $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException();
        }
        return new self($this->cents + $other->cents, $this->currency);
    }
}

$a = new Money(100, 'USD');
$b = $a->add(new Money(50, 'USD'));
// $a is still Money(100, 'USD')

Immutable value objects eliminate a whole class of bugs. They are safe to share across threads (relevant for async PHP), they compose cleanly into larger objects, and they document the relationships in the type system.

The pattern: use readonly for any type that represents a value (money, dates, IDs) rather than an entity (a user, an order).

Pipelines With array_map / array_filter / array_reduce

The classic functional pipeline: transform a collection through a sequence of operations.

$total = array_reduce(
    array_map(
        fn ($i) => $i->price * $i->quantity,
        array_filter($items, fn ($i) => $i->in_stock)
    ),
    fn ($acc, $price) => $acc + $price,
    0
);

PHP's native syntax makes this awkward because the function order reads inside-out. Laravel's Collection API is the more readable alternative for the same operations:

$total = collect($items)
    ->filter(fn ($i) => $i->in_stock)
    ->map(fn ($i) => $i->price * $i->quantity)
    ->sum();

Read top-to-bottom, no nesting. For PHP codebases that use Laravel, Collections are the natural FP fit. For framework-agnostic code, the array_* functions work; they are just less pretty.

Arrow Functions

PHP's arrow function (fn ($x) => ...) captures the outer scope automatically and is far less noisy than function ($x) use ($y) { return ...; }.

// Old style
$multiplier = 5;
$results = array_map(function ($x) use ($multiplier) { return $x * $multiplier; }, $numbers);

// Modern
$multiplier = 5;
$results = array_map(fn ($x) => $x * $multiplier, $numbers);

The limitation: arrow functions are single-expression only. For anything multi-line, you fall back to regular closures.

For functional-style PHP, arrow functions are essential. Without them, the syntax noise drowns out the actual logic.

Higher-Order Functions

Functions that take or return other functions. PHP supports them natively, but they are most useful when combined with named types.

function pipe(callable ...$fns): callable
{
    return fn ($input) => array_reduce(
        $fns,
        fn ($carry, $fn) => $fn($carry),
        $input
    );
}

$normalize = pipe(
    fn (string $s) => trim($s),
    fn (string $s) => strtolower($s),
    fn (string $s) => preg_replace('/\s+/', ' ', $s),
);

$result = $normalize('  Hello   World  ');
// "hello world"

The pipe helper composes any number of single-input functions into one. Useful for any case where you want a pipeline of transformations that you can name and reuse.

Match Expressions

Match is PHP's pattern-matching expression. It is functional in spirit — returns a value, no fallthrough, no mutation.

$label = match ($status) {
    'pending', 'queued' => 'In progress',
    'completed' => 'Done',
    'failed', 'error' => 'Failed',
    default => 'Unknown',
};

Cleaner than chained if/elseif. Strict equality semantics avoid the type-coercion bugs of switch. Use match over switch whenever you are returning a value.

Enum Methods

PHP 8.1 enums let you attach methods to enumerated values, giving you something close to algebraic data types.

enum Status: string
{
    case Pending = 'pending';
    case Active = 'active';
    case Cancelled = 'cancelled';

    public function isTerminal(): bool
    {
        return match ($this) {
            self::Cancelled => true,
            default => false,
        };
    }

    public function label(): string
    {
        return match ($this) {
            self::Pending => 'Pending review',
            self::Active => 'Active',
            self::Cancelled => 'Cancelled',
        };
    }
}

Methods on enums encapsulate the per-case behavior. The match is exhaustive — adding a new enum case forces you to handle it in every match (with default being a deliberate "everything else" choice).

Avoiding Mutation in Collections

PHP's array functions mostly return new arrays rather than mutating. The exceptions — sort, usort, array_push, unshift — are mutators. In functional code, avoid them.

// Mutates $items
sort($items);

// Returns a new sorted array
$sorted = collect($items)->sortBy(...)->all();

// Or, without Collections
$sorted = $items;
usort($sorted, fn ($a, $b) => $a->priority <=> $b->priority);

The discipline pays off: function calls that take an array do not surprise the caller by reshaping it.

What Functional PHP Cannot Do

  • No lazy evaluation. Every step of a pipeline is fully evaluated before the next runs. For huge collections, this is a memory problem.
  • No proper tail calls. Recursive solutions hit the stack limit on large inputs. Use iteration.
  • No persistent data structures. Copying a large array on "mutation" really does copy the array. Performance can surprise you.
  • No type safety on functions. A callable parameter does not enforce signature compatibility.

The first one is partially addressable with generators — PHP's yield produces lazy sequences. The others are language limits.

function lazyMap(iterable $input, callable $fn): Generator
{
    foreach ($input as $key => $value) {
        yield $key => $fn($value);
    }
}

Generators give you lazy evaluation for one-pass pipelines. Useful for processing large CSV files or streaming database results.

Where to Apply It

Functional techniques fit naturally in:

  • Value objects. Immutable, comparable by value, no identity.
  • Pure calculations. Pricing, scoring, validation, formatting.
  • Pipelines. Multi-step data transformations.
  • Domain logic. Pure functions of pure inputs.

They are less natural for:

  • Request handling. I/O, side effects, mutation are unavoidable.
  • Long-lived stateful objects. A user, an order, a connection — these are entities, not values.
  • Framework boundaries. Most frameworks expect mutable objects with public mutator methods.

Mixing functional and object-oriented PHP is normal. Use FP where it fits — the calculation layer of a service — and OO where it fits — the controllers, the entities, the framework interaction.

What This Buys You

Practical functional PHP is not about purity points. It is about:

  • Fewer mutation bugs (immutable types cannot be aliased and changed surprisingly)
  • More testable code (pure functions need no setup)
  • Clearer data flow (pipelines read like the transformations they describe)
  • Better composition (small functions combine into larger ones predictably)

The cost is some additional syntax and some discipline. The payoff is a codebase where the calculation-heavy parts are clearer and the bug surface is smaller.


Working on a PHP codebase where the calculation layer has acquired mutation bugs nobody saw coming? We help teams refactor for immutability and pipelines without forcing the whole stack into a foreign style. scopeforged.com

Share this article

Related Articles

Need help with your project?

Let's discuss how we can help you build reliable software.