Skip to main content

Monads in PHP: Explicit Outcomes and Clearer Composition

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.

6 min read

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 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};

$doubled = Some(21)->map(fn (int $value) => $value * 2);
// Some(42)

$default = None()->unwrapOr(0);
// 0

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(fn (Customer $customer) => loadSubscription($customer))
    ->andThen(fn (Subscription $subscription) => calculateBalance($subscription));

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.

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.

Want foundations like these in your codebase?

An architecture review gives you a written, prioritised plan. A foundations engagement puts it in place with your team, not for them.

  1. Baselines that only shrink: freezing technical debt without pretending it's gone

    5 min read

    How I use baselines to switch on strict static analysis today, hold new code to the full rule, and make sure the recorded debt can only shrink.

  2. Save Your Tests with Repositories in Laravel

    7 min read

    Tired of slow, brittle tests in Laravel? The repository pattern can help! Learn how to decouple your tests from the database, speed up execution, and write cleaner, more maintainable code.

  3. Strangle, don't rewrite: moving a live system onto a new tenancy model

    4 min read

    How to move a live, white-label system onto one tenancy model without a rewrite: one seam, one consumer at a time, fail closed, then delete the old path.