Pure Functions in PHP: Referential Transparency as the Foundation of Clean Code
AI generated
<?php
8.4
PHP · Functional Programming · Software Architecture
Pure Functions in PHP
Referential Transparency as the Foundation of Clean Code

A pure function always returns the same result for the same arguments and changes nothing outside its own return value. This principle of referential transparency makes PHP code predictable, easy to test and parallelizable, without any extra framework or new language syntax.

17 min read Pure Functions · Side Effects · Functional Core PHP 8.2 · 8.3 · 8.4

1. What pure functions and referential transparency really mean

A pure function satisfies two conditions at once: it always returns the same result for the same arguments, and it changes nothing outside its own return value, no database, no global variable, no property of an object passed in. This second condition is called freedom from side effects and is the actual core of every pure function. The consequence of both conditions together is called referential transparency: a call to a pure function can be replaced by its result at any time, without changing the behavior of the rest of the program.

In PHP this concept is not a new language extension but a discipline in how functions and methods are written. Any ordinary PHP function can be pure or impure, depending on whether it accesses or mutates external state. The difference between a function that calculates a tax amount and a function that additionally writes a log line is exactly this: the first is a pure function with referential transparency, the second no longer is, even though both contain the same core calculation.

The practical benefit shows up everywhere code needs to be correct, testable and safe under concurrency: price calculations, validation rules, data transformations and formatting logic can almost always be written as pure functions. This article shows how to recognize, write and cleanly separate such functions from unavoidable side effects, with runnable PHP 8.4 code in every section.

2. The traits of a pure function in detail

The first trait, determinism, means a pure function must never depend on anything outside its parameters. A function that internally reads date('Y'), rand() or a global constant is no longer deterministic, because its result depends on the moment it runs or on chance instead of solely on the arguments passed in. The second trait, freedom from side effects, forbids any change outside the function: no writing to a file, no database query, no mutation of an array passed by reference, no change to a static property.

A detail that is often overlooked: throwing an exception can also count as a side effect, when it models normal control flow through an exception instead of through a regular return value. Strictly functional languages therefore encode error cases through return types such as Result or Either instead of exceptions. In PHP this level of purism is rarely practical, but the awareness helps distinguish between expected validation errors, which should be returned as a value, and truly exceptional states, for which an exception remains appropriate.


<?php

declare(strict_types=1);

// IMPURE: depends on external state (current time) and has no
// deterministic output for the same $birthYear across calls
function calculateAgeImpure(int $birthYear): int
{
    return (int) date('Y') - $birthYear;
}

// PURE: same inputs always produce the same output, no hidden dependency
function calculateAge(int $birthYear, int $currentYear): int
{
    return $currentYear - $birthYear;
}

echo calculateAge(1990, 2026); // always 36, regardless of when it runs

// IMPURE: mutates the array passed in, a hidden side effect for the caller
function addDiscountImpure(array &$prices, float $percent): void
{
    foreach ($prices as $key => $price) {
        $prices[$key] = $price * (1 - $percent / 100);
    }
}

// PURE: returns a new array, leaves the original untouched
function withDiscount(array $prices, float $percent): array
{
    return array_map(
        fn (float $price): float => $price * (1 - $percent / 100),
        $prices,
    );
}

3. Referential transparency in practice: replacing an expression with its result

Referential transparency can be checked with a simple thought experiment: can a function call in the source code be replaced by its computed result without changing anything about the program's behavior? For a pure function such as calculateAge(1990, 2026), the answer is always yes, the expression can be replaced directly with 36. For a function that additionally writes a log line or increments a counter, that effect disappears when replaced by the plain value, breaking the substitution model.

This substitution principle is more than an academic exercise, it is the foundation for safe refactoring. When a compiler or a developer knows that an expression is referentially transparent, it can be moved, evaluated once instead of multiple times, or its result cached, without risking the program's behavior. Later sections on memoization and caching rely on exactly this property: only with referential transparency is a cache hit guaranteed correct, because identical inputs necessarily mean identical outputs.


<?php

declare(strict_types=1);

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

    // PURE: referentially transparent, can be replaced by its result
    public function add(Money $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException('Currency mismatch');
        }

        return new self($this->cents + $other->cents, $this->currency);
    }
}

$a = new Money(1000, 'EUR');
$b = new Money(250, 'EUR');

// This expression...
$total = $a->add($b);

// ...can always be substituted by its concrete result
$totalAgain = new Money(1250, 'EUR');

var_dump($total->cents === $totalAgain->cents); // true, every single time

4. Recognizing and isolating side effects

Fully pure code is impossible in a real application, because every application eventually needs to talk to a database, the filesystem, the clock or external services. The practical path is not to eliminate side effects but to push them to the edges of the architecture. This pattern is called Functional Core, Imperative Shell: the business logic that makes decisions and computes values stays a pure function at the core, while a thin outer layer performs the actual side effects, such as database access or HTTP calls.

In practice this means: a function computes whether a discount applies and how large it is, purely based on values passed in, without querying a database itself. The caller, the imperative shell, loads the required data beforehand, passes it to the pure function and persists the result afterward. This separation makes the core of the business logic testable without a database, without mocks and without a network, while the thin outer layer usually stays simple enough to rarely produce its own bugs.


<?php

declare(strict_types=1);

final class OrderLine
{
    public function __construct(
        public readonly string $sku,
        public readonly int $quantity,
        public readonly float $unitPrice,
    ) {
    }
}

// PURE core: all business logic, no I/O, no database, fully testable
function calculateOrderTotal(array $lines, float $loyaltyDiscount): float
{
    $subtotal = array_reduce(
        $lines,
        fn (float $carry, OrderLine $line): float => $carry + $line->quantity * $line->unitPrice,
        0.0,
    );

    return round($subtotal * (1 - $loyaltyDiscount), 2);
}

// IMPURE shell: the only place touching the database and the clock
final class OrderService
{
    public function __construct(private readonly OrderRepository $repository)
    {
    }

    public function checkout(int $orderId): float
    {
        $lines = $this->repository->findLines($orderId);          // side effect: DB read
        $discount = $this->repository->findLoyaltyDiscount($orderId); // side effect: DB read

        $total = calculateOrderTotal($lines, $discount);            // pure calculation

        $this->repository->saveTotal($orderId, $total);              // side effect: DB write

        return $total;
    }
}

5. Pure functions in classes: methods without this mutation

Object-oriented PHP code and pure functions are not mutually exclusive. A method is pure when it never mutates $this or the objects passed into it, but instead returns a new object with the computed result. readonly properties since PHP 8.1 support this pattern directly at the language level, because a property once set can never be changed afterward, which rules out many classic side effects from the outset.

The difference between a mutating setter method and a pure function-like method shows most clearly with value objects: instead of $money->addCents(500), which changes the state of $money, $money->add($other) returns a new object and leaves the original untouched. This pattern prevents an entire class of bugs where an object is held in multiple places in the code and a change in one place unexpectedly becomes visible in another, because both places share the same reference.


<?php

declare(strict_types=1);

final class ShoppingCart
{
    /**
     * @param list<OrderLine> $lines
     */
    public function __construct(private readonly array $lines = [])
    {
    }

    // PURE: returns a new cart, never mutates $this->lines
    public function withLine(OrderLine $line): self
    {
        return new self([...$this->lines, $line]);
    }

    // PURE: computed purely from immutable state, no hidden dependency
    public function total(): float
    {
        return array_reduce(
            $this->lines,
            fn (float $carry, OrderLine $line): float => $carry + $line->quantity * $line->unitPrice,
            0.0,
        );
    }
}

$cart = new ShoppingCart();
$updatedCart = $cart->withLine(new OrderLine('SKU-1', 2, 19.90));

// The original cart is provably unchanged — no mutation happened anywhere
var_dump($cart->total() === 0.0);        // true
var_dump($updatedCart->total() === 39.8); // true

6. Testability: why pure functions need no mocks

The biggest practical benefit of pure functions shows up when testing. A function without side effects needs no mock object, no in-memory database substitute, and no time manipulation to be tested predictably. The test calls the function with concrete input values and checks the return value, done. No database connection setup, no cleanup after the test, no ordering dependency between test cases, because no function leaves behind hidden state another test could see.

This effect compounds as the test suite grows: impure functions with database or filesystem access often lead to slow, sometimes flaky tests that depend on external infrastructure. Pure functions, by contrast, run in milliseconds, thousands of times in parallel, without interfering with each other. Anyone who consistently follows the functional core approach shifts most test cases onto these fast, robust unit tests and only needs a handful of integration tests for the thin, impure outer shell.


<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class CalculateOrderTotalTest extends TestCase
{
    // No mocks, no database, no setUp() needed — pure input, pure output
    public function testAppliesLoyaltyDiscountCorrectly(): void
    {
        $lines = [
            new OrderLine('SKU-1', 2, 10.0),
            new OrderLine('SKU-2', 1, 5.0),
        ];

        $total = calculateOrderTotal($lines, 0.1);

        self::assertSame(22.5, $total); // (20 + 5) * 0.9
    }

    public function testEmptyCartReturnsZero(): void
    {
        self::assertSame(0.0, calculateOrderTotal([], 0.2));
    }

    // Property-style test: the function is deterministic across many calls
    public function testIsDeterministicAcrossRepeatedCalls(): void
    {
        $lines = [new OrderLine('SKU-1', 3, 7.5)];

        $first = calculateOrderTotal($lines, 0.05);
        $second = calculateOrderTotal($lines, 0.05);

        self::assertSame($first, $second);
    }
}

7. Pure functions and performance: predictability and caching

Because a pure function is guaranteed to return the same result for identical arguments, its result can be cached without risk. This principle is the foundation of memoization, but also the theoretical justification for any form of result caching in PHP: only when a calculation is referentially transparent can a cache hit be guaranteed correct, without risking stale or wrong results. For an impure function that depends on external state, a cache would instead be a bug waiting to happen.

A second, less obvious advantage concerns concurrency: pure functions can run in parallel without synchronization, because they share no mutable state. In PHP with Fibers or external processes such as Swoole workers, this matters, because parallel calls to the same pure function can never influence each other through race conditions. The JIT compiler in PHP 8.4 also benefits from predictable, side-effect-free functions, because their control flow is easier to optimize than that of a function with unclear external dependencies.

8. Limits: when PHP code can never be fully pure

A web request handler in PHP can, by definition, never be fully pure, because its sole purpose is to produce a side effect, sending an HTTP response. Likewise, constructors that open a database connection, or repository methods that read and write records, remain impure by nature. The goal is therefore not to turn every single function of an application into a pure function, but to keep the share of pure code as large as possible and deliberately bundle the remaining side effects at clearly recognizable places.

A second practical edge case concerns logging and metrics within otherwise pure functions. Strictly speaking, every error_log() call is a side effect. In practice, such minimal effects, irrelevant to the return value, are often tolerated pragmatically, but should be documented consistently, so it stays clear which functions are truly fully referentially transparent and which are only nearly pure, with a deliberately accepted side effect for observability.

9. Pure functions in direct comparison

Not every area of code benefits equally from being consistently written as a pure function. The following table shows typical tasks in PHP applications and classifies how well suited they are to referentially transparent code.

Task Typical Approach Pure Function Fit Recommendation
Price and discount calculation Method with object mutation Very high Write as a pure function
Validation rules Throwing method with exception High Return the result as a value instead of an exception
Database access Repository method Never fully pure Isolate in a thin imperative shell
Timestamp-dependent logic date() called internally Low without adjustment Pass the timestamp in as a parameter
Sending an HTTP response Controller action Never pure Keep entirely at the edge of the architecture

The rule of thumb: the closer a function sits to actual domain logic, the greater the benefit of writing it as a pure function. The closer a function sits to infrastructure such as database, filesystem or network, the more it pays off to deliberately accept and clearly mark its side effects instead of artificially disguising them.

Mironsoft

PHP architecture, code reviews and functional patterns in everyday team work

Business logic that can be tested without mocks?

We review existing PHP code for hidden side effects and show how domain logic can be isolated as pure functions, cleanly separated from database, clock and network.

Code Review

Analysis for hidden side effects and missing referential transparency

Refactoring

Introducing Functional Core, Imperative Shell in existing modules

Training

Teaching pure functions and testability hands on within the team

10. Summary

Pure functions always return the same result for the same arguments and change nothing outside their return value. These two properties together produce referential transparency: a function call can be replaced by its result at any time, without changing the program's behavior. In PHP this principle can be applied consistently through the Functional Core, Imperative Shell pattern, by writing business logic as pure functions while a thin outer layer talks to database, filesystem and network.

The payoff shows up in three areas: tests no longer need mocks and run in milliseconds, refactoring becomes safer because expressions remain substitutable, and caching of results is guaranteed correct, because identical inputs necessarily yield identical outputs. Complete purity is impossible in a real application, but every additional pure function at the core of the domain logic noticeably reduces the surface area for unexpected bugs.

Pure Functions in PHP — The Key Takeaways

Definition

Same arguments always produce the same result, no change outside the return value.

Referential Transparency

A call can be replaced by its result at any time, the foundation for safe refactoring and caching.

Architecture Pattern

Functional Core, Imperative Shell separates pure domain logic from unavoidable side effects.

Testability

No mocks, no database, no ordering dependency between test cases needed.

11. FAQ: Pure Functions in PHP

1What is a pure function?
Always returns the same result for the same arguments and changes nothing outside its return value, no database, no global state.
2What does referential transparency mean?
A call can be replaced by its result without changing program behavior. Only holds for pure code.
3Can an app be entirely pure?
No, side effects like DB access or HTTP responses are unavoidable. Goal: maximize the pure share, bundle effects.
4Is a throwing method still pure?
Strictly not, it alters control flow. A Result type as return value is often the purer alternative.
5What is Functional Core, Imperative Shell?
Business logic as pure functions at the core, thin outer layer handles database, filesystem and network.
6Why easier to test?
No mock, no DB connection, no time manipulation needed. Concrete values in, check return value, done.
7Why are readonly properties relevant?
Prevent mutation at the language level, so methods tend to return new objects instead of changing state.
8Can a pure function log?
Strictly speaking logging is a side effect. Minimal, irrelevant log calls are often tolerated and documented.
9Advantage for caching?
Identical inputs guarantee identical outputs, so a cache hit is always correct.
10Relevant for concurrency?
Yes, no shared mutable state means parallel execution without race conditions, for example with Fibers or Swoole.