Functional Error Handling in PHP with Maybe and Either
AI generated
<?php
8.4
PHP · Functional Programming · Error Handling
Functional Error Handling
with Maybe and Either in PHP

Maybe and Either make an error state part of the return type, instead of hiding it implicitly behind null or a thrown exception. In PHP 8.4 both types can be fully rebuilt with readonly classes and enums, without any external library.

18 min read Maybe · Either · map · flatMap PHP 8.2 · 8.3 · 8.4

1. Why null and Exceptions Hide Error States

In classic PHP code, a function usually signals a failed state in one of two ways: it returns null, or it throws an exception. Both variants share a common drawback: the function signature's return type does not reliably reveal that an error case exists. A ?User findUser(int $id) can easily be called without a null check, and a thrown exception is not visible at all in the function header unless documented via PHPDoc with @throws.

Functional error handling with Maybe and Either solves this problem by making the success or failure state an explicit part of the return type. A function that returns Maybe forces the caller to actively handle the missing value before reaching the actual value. A function that returns Either additionally carries a concrete error message or error object, instead of only signaling "no value present".

The rest of this article shows the complete build-out of Maybe and Either in PHP 8.4, using readonly classes, named constructors, and typed map and flatMap methods that enable functional error handling without an external library.

2. Implementing a Custom Maybe Type

The Maybe type, also called Option in other languages, represents either the presence of a value (Some) or its absence (None), without carrying a reason for the absence. In PHP this can be implemented with an abstract readonly base class and two concrete subclasses, where named constructors like Maybe::some($value) and Maybe::none() keep construction readable.

The key difference from a plain ?T return type: Maybe offers methods like map and flatMap that only run a transformation when a value is actually present, and otherwise automatically pass through None. This eliminates manual if ($value !== null) checks at every single transformation point.


<?php

declare(strict_types=1);

/**
 * @template T
 */
abstract readonly class Maybe
{
    /**
     * @template U
     * @param U $value
     * @return Maybe<U>
     */
    public static function some(mixed $value): self
    {
        return new Some($value);
    }

    /**
     * @return Maybe<never>
     */
    public static function none(): self
    {
        return new None();
    }

    abstract public function isSome(): bool;

    /**
     * @template U
     * @param Closure(T): U $fn
     * @return Maybe<U>
     */
    abstract public function map(Closure $fn): self;

    /**
     * @template U
     * @param Closure(T): Maybe<U> $fn
     * @return Maybe<U>
     */
    abstract public function flatMap(Closure $fn): self;

    /**
     * @param T $default
     * @return T
     */
    abstract public function getOrElse(mixed $default): mixed;
}

/**
 * @template T
 * @extends Maybe<T>
 */
final readonly class Some extends Maybe
{
    public function __construct(private mixed $value)
    {
    }

    public function isSome(): bool
    {
        return true;
    }

    public function map(Closure $fn): Maybe
    {
        return Maybe::some($fn($this->value));
    }

    public function flatMap(Closure $fn): Maybe
    {
        return $fn($this->value);
    }

    public function getOrElse(mixed $default): mixed
    {
        return $this->value;
    }
}

/**
 * @template T
 * @extends Maybe<T>
 */
final readonly class None extends Maybe
{
    public function isSome(): bool
    {
        return false;
    }

    public function map(Closure $fn): Maybe
    {
        return $this; // None stays None — the transformation never runs
    }

    public function flatMap(Closure $fn): Maybe
    {
        return $this;
    }

    public function getOrElse(mixed $default): mixed
    {
        return $default;
    }
}

3. Maybe in Practice: map, flatMap, getOrElse

With the Maybe type from the previous section, transformation chains can be written without explicitly checking for null at every point. A function that looks up a customer by email address returns Maybe<Customer> instead of ?Customer. Subsequent steps such as extracting the name or formatting a greeting are chained via map and automatically fizzle out once the original customer was not found.

The difference between map and flatMap is crucial: map expects a function that returns a plain value, while flatMap is meant for functions that themselves already return a Maybe. If map is accidentally used with a function that returns a Maybe itself, a nested Maybe<Maybe<T>> results, which complicates further chaining. flatMap automatically "flattens" that result.


<?php

declare(strict_types=1);

final readonly class Customer
{
    public function __construct(
        public string $email,
        public string $firstName,
    ) {
    }
}

/**
 * @param array<string, Customer> $registry
 * @return Maybe<Customer>
 */
function findCustomerByEmail(array $registry, string $email): Maybe
{
    return isset($registry[$email])
        ? Maybe::some($registry[$email])
        : Maybe::none();
}

$registry = [
    'jane@example.com' => new Customer('jane@example.com', 'Jane'),
];

$greeting = findCustomerByEmail($registry, 'jane@example.com')
    ->map(fn (Customer $c): string => $c->firstName)
    ->map(fn (string $name): string => "Hello, {$name}!")
    ->getOrElse('Hello, guest!');

echo $greeting; // Hello, Jane!

$missingGreeting = findCustomerByEmail($registry, 'unknown@example.com')
    ->map(fn (Customer $c): string => $c->firstName)
    ->map(fn (string $name): string => "Hello, {$name}!")
    ->getOrElse('Hello, guest!');

echo $missingGreeting; // Hello, guest!

4. Implementing a Custom Either Type

While Maybe only distinguishes between "value present" and "no value", Either additionally carries concrete information about what went wrong in the failure case. By convention, Left represents the failure case and Right the success case, a naming convention from functional programming that has become established across languages. In PHP this can be implemented analogously to Maybe with an abstract readonly base class.

The practical advantage of Either over Maybe shows up in validation: instead of only knowing that an input was invalid, the caller learns the exact error message via the Left branch, for example "invalid email format" or "password too short". This makes Either the natural choice for functions whose error cause needs to be communicated to the user or the log.


<?php

declare(strict_types=1);

/**
 * @template L
 * @template R
 */
abstract readonly class Either
{
    /**
     * @template TL
     * @param TL $value
     * @return Either<TL, never>
     */
    public static function left(mixed $value): self
    {
        return new Left($value);
    }

    /**
     * @template TR
     * @param TR $value
     * @return Either<never, TR>
     */
    public static function right(mixed $value): self
    {
        return new Right($value);
    }

    abstract public function isRight(): bool;

    /**
     * @template U
     * @param Closure(R): U $fn
     * @return Either<L, U>
     */
    abstract public function map(Closure $fn): self;

    /**
     * @template TL
     * @template TR
     * @param Closure(L): TL $onLeft
     * @param Closure(R): TR $onRight
     * @return TL|TR
     */
    abstract public function match(Closure $onLeft, Closure $onRight): mixed;
}

/**
 * @template L
 * @extends Either<L, never>
 */
final readonly class Left extends Either
{
    public function __construct(private mixed $value)
    {
    }

    public function isRight(): bool
    {
        return false;
    }

    public function map(Closure $fn): Either
    {
        return $this; // Left stays Left — the transformation never runs
    }

    public function match(Closure $onLeft, Closure $onRight): mixed
    {
        return $onLeft($this->value);
    }
}

/**
 * @template R
 * @extends Either<never, R>
 */
final readonly class Right extends Either
{
    public function __construct(private mixed $value)
    {
    }

    public function isRight(): bool
    {
        return true;
    }

    public function map(Closure $fn): Either
    {
        return Either::right($fn($this->value));
    }

    public function match(Closure $onLeft, Closure $onRight): mixed
    {
        return $onRight($this->value);
    }
}

5. Either in Practice: Validation with Error Details

A typical use case for Either is validating form input, where every failure needs to carry a meaningful reason. A validateEmail function returns Either::left('invalid email format') on an invalid format, and Either::right($email) on success. The caller uses match to handle both cases explicitly, without a branch ever being accidentally forgotten, since match mandates both closures.

This form of functional error handling is especially well suited for API endpoints that must return precise error messages to the client. Instead of a generic exception with an unclear message, the Left branch of Either delivers exactly the information needed for a meaningful HTTP response with status code 422 and error detail.


<?php

declare(strict_types=1);

/**
 * @return Either<string, string>
 */
function validateEmail(string $email): Either
{
    if (!str_contains($email, '@')) {
        return Either::left('invalid email format');
    }

    return Either::right($email);
}

/**
 * @return Either<string, string>
 */
function validatePasswordLength(string $password): Either
{
    if (strlen($password) < 8) {
        return Either::left('password too short, minimum 8 characters');
    }

    return Either::right($password);
}

$emailResult = validateEmail('not-an-email');

$message = $emailResult->match(
    onLeft: fn (string $error): string => "Error: {$error}",
    onRight: fn (string $email): string => "Valid: {$email}",
);

echo $message; // Error: invalid email format

6. Chained Operations Without Nested if Blocks

The real value of Maybe and Either shows when several validation or lookup steps have to run one after another, each of which can fail. Without these types, deeply nested if blocks or repeated null checks after every step tend to appear. With flatMap, several Either-returning functions can be joined into a single chain that automatically stops at the first failure.

This chaining is structurally related to the pipe operator simulation, but differs in one important respect: while a plain pipeline only passes on null on failure, the Either chain carries the concrete error cause all the way to the end. That makes functional error handling with Either especially valuable for multi-step validations, where the user needs to know exactly which step failed.


<?php

declare(strict_types=1);

/**
 * Chains two validators — stops at the first Left encountered.
 *
 * @return Either<string, string>
 */
function validateRegistration(string $email, string $password): Either
{
    return validateEmail($email)
        ->map(fn (string $validEmail): string => $validEmail) // pass through
        ->match(
            onLeft: fn (string $error): Either => Either::left($error),
            onRight: fn (string $validEmail): Either => validatePasswordLength($password)
                ->match(
                    onLeft: fn (string $error): Either => Either::left($error),
                    onRight: fn (string $validPassword): Either => Either::right($validEmail),
                ),
        );
}

$result = validateRegistration('user@example.com', 'short');

echo $result->match(
    onLeft: fn (string $error): string => "Registration failed: {$error}",
    onRight: fn (string $email): string => "Registration successful for {$email}",
);
// Registration failed: password too short, minimum 8 characters

7. Typing with PHPStan: Generic Maybe and Either Types

The PHPDoc annotations @template T on Maybe and @template L/@template R on Either are essential for PHPStan to track the concrete value type through an entire chain of map and flatMap calls. Without these templates, every method would return only mixed, and PHPStan could not catch a single type error in the chain, even when a transformation obviously leads to the wrong return type.

With templates set up correctly, PHPStan from level 6 upward checks whether getOrElse is called with a default value matching the generic type, and whether match returns compatible types for both branches. This investment in typing pays off especially in large codebases where Maybe and Either are used in many places at once and manual type checking is no longer practical.

8. Limits: When Exceptions Remain the Better Choice

Maybe and Either are not a universal replacement for every kind of error handling in PHP. For genuine exceptional states meant to interrupt normal program flow, such as a failed database connection or a missing configuration file at system startup, a thrown exception remains the right choice. These states are usually not part of the expected business logic but real infrastructure failures, where an immediate abort is more appropriate than explicit error handling at every call site.

The rule of thumb: Maybe and Either fit expected, business-relevant error states that are part of normal program flow, such as an entity that was not found or invalid user input. Exceptions remain the right choice for unexpected, technical failures, where aborting the program or relying on central framework-level error handling is the more appropriate response than a local check.

9. Maybe, Either, null and Exceptions Compared

The following table contrasts the four common error-handling strategies in PHP and shows what information each variant carries and when it fits.

Strategy Error Information Visible in Signature Suited For
null return None, only absence Partially (?T) Simple, rare not-found cases
Exception Yes, in the exception object No, only via @throws Unexpected, technical failures
Maybe None, only absence Yes, in the return type Expected not-found cases
Either Yes, concrete in the Left branch Yes, in the return type Validation with error details

In practice, all four strategies complement each other: Maybe for simple lookups, Either for validations with a concrete error cause, and exceptions for technical exceptional states outside the expected business logic. null as a return type remains acceptable for very local, simple cases where the extra effort of Maybe brings no practical benefit.

Mironsoft

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

Making error states explicit in the type system?

We show where Maybe and Either can replace nested null checks and unclear exceptions in your PHP code with explicit, type-safe error handling.

Code Review

Identifying implicit null returns and checking suitability for Maybe/Either

Refactoring

Moving validation logic to explicit Either types with concrete error messages

Training

Introducing functional error handling hands-on, including PHPStan templates

10. Summary

Maybe and Either make error states an explicit part of the return type, instead of hiding them behind null or invisibly thrown exceptions. Maybe only distinguishes between the presence and absence of a value, while Either additionally carries a concrete error cause via the Left branch on failure. Both types can be fully implemented in PHP 8.4 with readonly classes, named constructors, and typed map, flatMap and match methods, without an external library.

Functional error handling with these types fits especially well for expected, business-relevant error cases like validations or lookups, while exceptions remain the right choice for unexpected, technical failures. With PHPStan templates, the concrete value type can be tracked through entire chains, which surfaces typos and type mismatches before execution.

Functional Error Handling with Maybe and Either — The Key Points at a Glance

Maybe

Some or None, distinguishes only presence and absence, with no error cause.

Either

Left or Right, additionally carries a concrete error cause on failure.

map and flatMap

Chain transformations without every step manually checking for null or failure.

Limits

For unexpected, technical failures, classic exceptions remain the right choice.

11. FAQ: Functional Error Handling in PHP with Maybe and Either

1Maybe vs. Either?
Maybe distinguishes only presence and absence. Either additionally carries a concrete error via Left.
2Native in PHP?
No language feature. Fully self-implementable with readonly classes and named constructors.
3map vs. flatMap?
map expects a plain return value. flatMap is for functions that already return a Maybe or Either themselves.
4When Maybe over ?T?
With several chained transformation steps. A single call is often fine with a plain ?T type.
5Does Either replace exceptions?
No. For business errors like validations yes, for unexpected, technical failures exceptions remain correct.
6PHPStan checking of chains?
With @template annotations, PHPStan from level 6 tracks the value type through the entire chain.
7What do Left and Right mean?
Left conventionally means failure, Right means success, an established naming convention from functional programming.
8Handling both branches safely?
With match, which mandates two closures, so no branch can be accidentally forgotten.
9Does it complicate the code?
Slightly for single calls, usually a clear simplification over manual null checks for chained steps.
10Need an external library?
No, a few readonly classes with named constructors are enough for a complete self-implementation.