---
title: "Monads in PHP: Explicit Outcomes and Clearer Composition"
url: "https://joelbutcher.dev/writing/monads-in-php-explicit-outcomes-and-clearer-composition"
published: "2026-10-30T15:00:00+00:00"
tags: ["Architecture", "Laravel/PHP"]
description: "Monads make absence, failure and other computation contexts explicit. Here's how I use Option, Result, Lazy and Writer in PHP to compose application logic more clearly."
---

# Monads in PHP: Explicit Outcomes and Clearer Composition

Monads are useful when they make the possible outcomes of a computation explicit and give us a consistent way to compose operations. In PHP, I reach for them when nullable values, expected failures or chains of transformations start obscuring the business logic. The [gosuperscript/monads-php](https://github.com/gosuperscript/monads-php) library brings four small, typed abstractions to that work: `Option`, `Result`, `Lazy` and `Writer`.

I want to explain these through practical PHP patterns, rather than start with category theory. The terminology matters, but the practical question comes first: how do we make code easier to reason about when an operation may have no value, may fail, may be expensive, or needs to carry some record of what happened?

## Start with absence: Option

PHP already lets us express an optional return value with a nullable type:

```php
function findCustomer(string $email): ?Customer
{
    // Return null when no customer matches.
}
```

That's often enough. The trouble starts when several operations depend on the previous one. Each step needs to check for `null`, and the business flow can disappear beneath the branching.

An `Option` makes absence an explicit value: either `Some` containing a value, or `None` representing no value.

```php
use function Superscript\\Monads\\Option\\{Some, None};

final readonly class ProcessOrder
{
    public function __construct(FindOrder $findOrder) {}

    public function execute(OrderId $id): void
    {
        $this->findOrder->byId($id)
            ->andThen($this->update(...))
            ->andThen(fn() => event(new OrderProcessed($id)));
    }

    private function update(Order $order): Order
    {
        return tap($order, fn(Order $order) => $order->update([
            ‘processed_at’ => now(),
        ]));
    }
}
```

The important part isn't that the wrapper is cleverer than `null`. It's that operations on an `Option` have a consistent contract. `map` transforms the value when one exists and leaves `None` alone. `andThen` (also known as `flatMap`) composes a function that itself returns an `Option`, without nesting wrappers.

That gives absence a place in the type system and keeps the caller from having to repeatedly unpack it.

## Failure is not absence: Result

A missing customer and a failed database connection are not the same outcome. Neither should be silently treated as the other.

`Result` represents either success, `Ok(value)`, or failure, `Err(error)`. A function can make its expected failure modes part of its return contract.

```php
use function Superscript\\Monads\\Result\\{Ok, Err};

function divide(int $numerator, int $denominator): Result
{
    if ($denominator === 0) {
        return Err('Division by zero');
    }

    return Ok($numerator / $denominator);
}

$result = divide(10, 2)
    ->map(fn (float $value) => $value * 2)
    ->unwrapOr(0);
```

A `Result` can be transformed with `map`: the callback runs for `Ok`, while an `Err` is propagated unchanged. `andThen` chains another operation that also returns a `Result`. If a step fails, later success-path operations are skipped.

This doesn't mean exceptions are bad or should disappear. Exceptions remain useful for unexpected failures and at boundaries where they are the right contract. For expected, recoverable outcomes, a `Result` can make alternatives visible and help callers handle them deliberately.

The library also provides `attempt()` for wrapping code that may throw in a `Result`. That can be useful when adapting exception-based APIs, but it shouldn't become a reason to catch every throwable and erase distinctions that matter.

## Composition is the real benefit

The payoff appears when operations are chained. Instead of adding a new conditional after every step, each function returns the same kind of context and the next operation is composed through it.

```php
$result = loadCustomer($customerId)
    ->andThen(loadSubscription(...))
    ->andThen(calculateBalance(...));
```

This is an illustrative shape: each function must return a compatible `Result`, and the domain types are examples. The chain reads like the intended workflow, while the result type carries success or failure through it.

At the edge of the application, `match()` can make both outcomes explicit:

```php
$response = $result->match(
    err: fn ($error) => response()->json(['error' => $error], 400),
    ok: fn ($balance) => response()->json(['balance' => $balance]),
);
```

The controller translates domain outcomes into HTTP responses. The domain code doesn't need to know about HTTP, and the controller doesn't have to guess what a `null` or an arbitrary exception means.

## Two other useful contexts: Lazy and Writer

`Option` and `Result` are the most immediately recognisable examples, but the library includes two other abstractions that show the broader idea.

`Lazy` represents a computation deferred until its value is needed. Its `evaluate()` method evaluates the computation and memoises the result.

```php
use Superscript\\Monads\\Lazy\\Lazy;

$report = Lazy::of(fn () => buildExpensiveReport());

// The work has not run yet.
if ($shouldShowReport) {
    $data = $report->evaluate();
}
```

That can be useful when a computation is expensive and might never be needed. Deferred work should still be placed where its timing and consequences are clear, especially if it performs I/O or has side effects.

`Writer` carries a value alongside an accumulated log. Chained operations can transform the value while adding entries to that log.

```php
use function Superscript\\Monads\\Writer\\Writer;

$result = Writer(100.0, ['Starting price: 100.00'])
    ->andThen(fn (float $price) => Writer($price * 1.2, ['Tax applied']))
    ->andThen(fn (float $price) => Writer($price * 0.9, ['Discount applied']));

$price = $result->value();
$steps = $result->log();
```

This is an illustrative calculation, not a recommendation to use `Writer` as a replacement for operational logging or an audit system. Its value is that a computation can return a result and accumulated information together, without each function needing to mutate shared state.

## Use the abstraction when it earns its place

These types are tools, not a mandate to wrap every value. For a simple Eloquent lookup, `?Customer` may be perfectly clear. Laravel's validation, exception handling and HTTP conventions are useful, established parts of the framework; replacing them wholesale would add friction rather than clarity.

I consider monads when a domain operation has meaningful alternative outcomes, when several fallible steps need to compose, or when a computation needs a clear context such as deferred evaluation or accumulated output. The benefit should be visible in the calling code, and the team should understand the conventions being introduced.

The `gosuperscript/monads-php` package is a PHP 8.3+ library inspired by Rust's `Option` and `Result` types. It includes PHPStan generics support, so these contexts can be represented in a way static analysis can understand. The API and examples are documented in the [project repository](https://github.com/gosuperscript/monads-php).

I don't use monads because every function needs a clever abstraction. I use them when they make the contract clearer: absence is represented as absence, expected failure is represented as failure, and the steps between them compose without obscuring the business rule.
