Funktionale Fehlerbehandlung mit Maybe und Either in PHP
AI generated
<?php
8.4
PHP · Funktionale Programmierung · Fehlerbehandlung
Funktionale Fehlerbehandlung
mit Maybe und Either in PHP

Maybe und Either machen einen Fehlerzustand zum Bestandteil des Rückgabetyps, statt ihn implizit über null oder eine geworfene Exception zu verstecken. In PHP 8.4 lassen sich beide Typen mit readonly-Klassen und Enums vollständig ohne externe Bibliothek nachbauen.

18 Min. Lesezeit Maybe · Either · map · flatMap PHP 8.2 · 8.3 · 8.4

1. Warum null und Exceptions Fehlerzustände verstecken

In klassischem PHP-Code signalisiert eine Funktion einen fehlgeschlagenen Zustand meist auf zwei Wegen: sie gibt null zurück, oder sie wirft eine Exception. Beide Varianten haben einen gemeinsamen Nachteil: der Rückgabetyp der Funktionssignatur verrät nicht zuverlässig, dass ein Fehlerfall existiert. Ein ?User findUser(int $id) lässt sich leicht ohne Null-Check aufrufen, und eine geworfene Exception ist im Funktionskopf überhaupt nicht sichtbar, sofern sie nicht per PHPDoc mit @throws dokumentiert wurde.

Funktionale Fehlerbehandlung mit Maybe und Either löst dieses Problem, indem der Erfolgs- oder Fehlerzustand explizit Teil des Rückgabetyps wird. Eine Funktion, die Maybe zurückgibt, zwingt den Aufrufer, den fehlenden Wert aktiv zu behandeln, bevor er an den eigentlichen Wert herankommt. Eine Funktion, die Either zurückgibt, transportiert zusätzlich eine konkrete Fehlermeldung oder ein Fehlerobjekt, statt nur "kein Wert vorhanden" zu signalisieren.

Der Rest dieses Artikels zeigt den vollständigen Aufbau von Maybe und Either in PHP 8.4, mit readonly-Klassen, Named Constructors und typisierten map- und flatMap-Methoden, die funktionale Fehlerbehandlung ohne externe Bibliothek ermöglichen.

2. Einen eigenen Maybe-Typ implementieren

Der Maybe-Typ, in anderen Sprachen auch Option genannt, repräsentiert entweder das Vorhandensein eines Werts (Some) oder dessen Abwesenheit (None), ohne dabei einen Grund für die Abwesenheit zu transportieren. In PHP lässt sich das mit einer abstrakten readonly-Basisklasse und zwei konkreten Unterklassen umsetzen, wobei Named Constructors wie Maybe::some($wert) und Maybe::none() die Erzeugung lesbar halten.

Der entscheidende Unterschied zu einem einfachen ?T-Rückgabetyp: Maybe bietet Methoden wie map und flatMap, die eine Transformation nur dann ausführen, wenn tatsächlich ein Wert vorhanden ist, und andernfalls automatisch None durchreichen. Dadurch entfallen manuelle if ($wert !== null)-Prüfungen an jeder einzelnen Transformationsstelle.


<?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 der Praxis: map, flatMap, getOrElse

Mit dem Maybe-Typ aus dem vorherigen Abschnitt lassen sich Transformationsketten schreiben, ohne an jeder Stelle explizit auf null zu prüfen. Eine Funktion, die einen Kunden anhand seiner E-Mail-Adresse sucht, gibt Maybe<Customer> zurück, statt ?Customer. Nachfolgende Schritte wie das Extrahieren des Namens oder das Formatieren einer Begrüßung werden über map verkettet und laufen automatisch ins Leere, sobald der ursprüngliche Kunde nicht gefunden wurde.

Der Unterschied zwischen map und flatMap ist entscheidend: map erwartet eine Funktion, die einen normalen Wert zurückgibt, während flatMap für Funktionen gedacht ist, die selbst bereits ein Maybe zurückgeben. Wird map versehentlich mit einer Funktion verwendet, die selbst ein Maybe liefert, entsteht ein verschachteltes Maybe<Maybe<T>>, was die weitere Verkettung erschwert. flatMap "flacht" dieses Ergebnis automatisch ab.


<?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 => "Hallo, {$name}!")
    ->getOrElse('Hallo, Gast!');

echo $greeting; // Hallo, Jane!

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

echo $missingGreeting; // Hallo, Gast!

4. Einen eigenen Either-Typ implementieren

Während Maybe nur zwischen "Wert vorhanden" und "kein Wert" unterscheidet, transportiert Either im Fehlerfall zusätzlich eine konkrete Information darüber, was schiefgelaufen ist. Konventionell steht Left für den Fehlerfall und Right für den Erfolgsfall, eine Namenskonvention aus der funktionalen Programmierung, die sich unabhängig von der jeweiligen Sprache durchgesetzt hat. In PHP lässt sich das analog zu Maybe mit einer abstrakten readonly-Basisklasse umsetzen.

Der praktische Vorteil von Either gegenüber Maybe zeigt sich bei Validierungen: statt nur zu wissen, dass eine Eingabe ungültig war, kennt der Aufrufer über den Left-Zweig die genaue Fehlermeldung, etwa "E-Mail-Format ungültig" oder "Passwort zu kurz". Das macht Either zur natürlichen Wahl für Funktionen, deren Fehlerursache dem Anwender oder Log kommuniziert werden muss.


<?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 der Praxis: Validierung mit Fehlerdetails

Ein typisches Einsatzgebiet für Either ist die Validierung von Formulareingaben, bei der jeder Fehlschlag einen aussagekräftigen Grund transportieren muss. Eine validateEmail-Funktion gibt bei einem ungültigen Format Either::left('E-Mail-Format ungültig') zurück, bei Erfolg Either::right($email). Der Aufrufer nutzt match, um beide Fälle explizit zu behandeln, ohne dass ein Zweig versehentlich vergessen werden kann, da match beide Closures zwingend verlangt.

Diese Form der funktionalen Fehlerbehandlung eignet sich besonders für API-Endpunkte, die dem Client präzise Fehlermeldungen zurückgeben müssen. Statt einer generischen Exception mit unklarer Nachricht liefert der Left-Zweig von Either exakt die Information, die für eine sinnvolle HTTP-Antwort mit Statuscode 422 und Fehlerdetail benötigt wird.


<?php

declare(strict_types=1);

/**
 * @return Either<string, string>
 */
function validateEmail(string $email): Either
{
    if (!str_contains($email, '@')) {
        return Either::left('E-Mail-Format ungültig');
    }

    return Either::right($email);
}

/**
 * @return Either<string, string>
 */
function validatePasswordLength(string $password): Either
{
    if (strlen($password) < 8) {
        return Either::left('Passwort zu kurz, mindestens 8 Zeichen');
    }

    return Either::right($password);
}

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

$message = $emailResult->match(
    onLeft: fn (string $error): string => "Fehler: {$error}",
    onRight: fn (string $email): string => "Gültig: {$email}",
);

echo $message; // Fehler: E-Mail-Format ungültig

6. Verkettete Operationen ohne verschachtelte if-Blöcke

Der eigentliche Mehrwert von Maybe und Either zeigt sich, wenn mehrere Validierungs- oder Suchschritte hintereinander ausgeführt werden müssen, deren jeder einzelne fehlschlagen kann. Ohne diese Typen entstehen tief verschachtelte if-Blöcke oder wiederholte Null-Checks nach jedem Schritt. Mit flatMap lassen sich mehrere Either-liefernde Funktionen zu einer einzigen Kette verbinden, die beim ersten Fehler automatisch abbricht.

Diese Verkettung ist strukturell verwandt mit der Pipe Operator Simulation, unterscheidet sich aber in einem wichtigen Punkt: während eine einfache Pipeline bei einem Fehler nur null weiterreicht, trägt die Either-Kette bis zum Ende die konkrete Fehlerursache mit sich. Das macht funktionale Fehlerbehandlung mit Either besonders wertvoll für mehrstufige Validierungen, bei denen der Nutzer wissen muss, welcher genaue Schritt fehlgeschlagen ist.


<?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 => "Registrierung fehlgeschlagen: {$error}",
    onRight: fn (string $email): string => "Registrierung erfolgreich für {$email}",
);
// Registrierung fehlgeschlagen: Passwort zu kurz, mindestens 8 Zeichen

7. Typisierung mit PHPStan: generische Maybe- und Either-Typen

Die PHPDoc-Annotationen @template T auf Maybe und @template L/@template R auf Either sind entscheidend, damit PHPStan den konkreten Werttyp durch eine ganze Kette von map- und flatMap-Aufrufen hindurch verfolgen kann. Ohne diese Templates würde jede Methode nur mixed zurückgeben, und PHPStan könnte keinen einzigen Typfehler in der Kette erkennen, selbst wenn eine Transformation offensichtlich zum falschen Rückgabetyp führt.

Mit korrekt gesetzten Templates prüft PHPStan ab Level 6 aufwärts, ob getOrElse mit einem zum generischen Typ passenden Standardwert aufgerufen wird, und ob match für beide Zweige kompatible Rückgabetypen liefert. Diese Investition in die Typisierung zahlt sich besonders in großen Codebasen aus, in denen Maybe und Either an vielen Stellen parallel verwendet werden und manuelle Typprüfung nicht mehr praktikabel wäre.

8. Grenzen: wann Exceptions die bessere Wahl bleiben

Maybe und Either sind keine universelle Ersatzlösung für jede Fehlerbehandlung in PHP. Für echte Ausnahmezustände, die den normalen Programmfluss unterbrechen sollen, etwa eine fehlgeschlagene Datenbankverbindung oder ein nicht vorhandenes Konfigurationsfile beim Systemstart, bleibt eine geworfene Exception die richtige Wahl. Diese Zustände sind meist nicht Teil der erwarteten Geschäftslogik, sondern echte Infrastrukturfehler, bei denen ein sofortiger Abbruch angemessener ist als eine explizite Fehlerbehandlung an jeder Aufrufstelle.

Die Faustregel: Maybe und Either eignen sich für erwartete, fachlich relevante Fehlerzustände, die Teil des normalen Programmablaufs sind, etwa eine nicht gefundene Entität oder eine ungültige Benutzereingabe. Exceptions bleiben die richtige Wahl für unerwartete, technische Fehler, bei denen ein Programmabbruch oder eine zentrale Fehlerbehandlung im Framework die angemessenere Reaktion ist als eine lokale Prüfung.

9. Maybe, Either, null und Exceptions im Vergleich

Die folgende Tabelle stellt die vier gängigen Strategien zur Fehlerbehandlung in PHP gegenüber und zeigt, welche Information jede Variante transportiert und wann sie geeignet ist.

Strategie Fehlerinformation Sichtbar in Signatur Geeignet für
null-Rückgabe Keine, nur Abwesenheit Teilweise (?T) Einfache, seltene Nichtfunde
Exception Ja, im Exception-Objekt Nein, nur per @throws Unerwartete, technische Fehler
Maybe Keine, nur Abwesenheit Ja, im Rückgabetyp Erwartete Nichtfunde
Either Ja, konkret im Left-Zweig Ja, im Rückgabetyp Validierung mit Fehlerdetails

In der Praxis ergänzen sich alle vier Strategien: Maybe für einfache Suchvorgänge, Either für Validierungen mit konkreter Fehlerursache, und Exceptions für technische Ausnahmezustände außerhalb der erwarteten Geschäftslogik. null als Rückgabetyp bleibt akzeptabel für sehr lokale, einfache Fälle, in denen der zusätzliche Aufwand von Maybe keinen praktischen Mehrwert bringt.

Mironsoft

PHP-Architektur, Code-Reviews und moderne Sprachfeatures im Team-Alltag

Fehlerzustände explizit im Typsystem abbilden?

Wir zeigen, wo Maybe und Either verschachtelte Null-Checks und unklare Exceptions in eurem PHP-Code durch explizite, typsichere Fehlerbehandlung ersetzen können.

Code-Review

Implizite null-Rückgaben identifizieren und auf Maybe/Either-Eignung prüfen

Refactoring

Validierungslogik auf explizite Either-Typen mit konkreten Fehlermeldungen umstellen

Schulung

Funktionale Fehlerbehandlung praxisnah einführen, inklusive PHPStan-Templates

10. Zusammenfassung

Maybe und Either machen Fehlerzustände zum expliziten Bestandteil des Rückgabetyps, statt sie implizit über null oder unsichtbar geworfene Exceptions zu verstecken. Maybe unterscheidet nur zwischen Vorhandensein und Abwesenheit eines Werts, während Either im Fehlerfall zusätzlich eine konkrete Fehlerursache über den Left-Zweig transportiert. Beide Typen lassen sich in PHP 8.4 mit readonly-Klassen, Named Constructors und typisierten map-, flatMap- und match-Methoden vollständig ohne externe Bibliothek umsetzen.

Funktionale Fehlerbehandlung mit diesen Typen eignet sich besonders für erwartete, fachlich relevante Fehlerfälle wie Validierungen oder Suchvorgänge, während Exceptions für unerwartete, technische Fehler die richtige Wahl bleiben. Mit PHPStan-Templates lässt sich der konkrete Werttyp durch ganze Verkettungen hindurch verfolgen, was Tippfehler und Typinkompatibilitäten schon vor der Ausführung sichtbar macht.

Funktionale Fehlerbehandlung mit Maybe und Either — Das Wichtigste auf einen Blick

Maybe

Some oder None, unterscheidet nur Vorhandensein und Abwesenheit, ohne Fehlerursache.

Either

Left oder Right, transportiert im Fehlerfall zusätzlich eine konkrete Fehlerinformation.

map und flatMap

Verketten Transformationen, ohne dass jeder Schritt manuell auf null oder Fehler prüft.

Grenzen

Für unerwartete, technische Fehler bleiben klassische Exceptions die richtige Wahl.

11. FAQ: Funktionale Fehlerbehandlung mit Maybe und Either in PHP

1Maybe vs. Either?
Maybe unterscheidet nur Vorhandensein und Abwesenheit. Either transportiert zusätzlich eine konkrete Fehlerinformation über Left.
2Nativ in PHP vorhanden?
Nein, kein Sprachfeature. Vollständig selbst implementierbar mit readonly-Klassen und Named Constructors.
3map vs. flatMap?
map erwartet einen normalen Rückgabewert. flatMap ist für Funktionen, die selbst bereits ein Maybe oder Either liefern.
4Wann Maybe statt ?T?
Bei mehreren verketteten Transformationsschritten. Für einen einzelnen Aufruf reicht oft ein einfacher ?T-Typ.
5Ersetzt Either Exceptions?
Nein. Für fachliche Fehler wie Validierungen ja, für unerwartete, technische Fehler bleiben Exceptions richtig.
6PHPStan-Prüfung der Ketten?
Mit @template-Annotationen verfolgt PHPStan ab Level 6 den Werttyp durch die gesamte Kette hindurch.
7Was bedeuten Left und Right?
Left steht konventionell für den Fehlerfall, Right für den Erfolgsfall, eine etablierte Namenskonvention aus der funktionalen Programmierung.
8Beide Zweige sicher behandeln?
Mit match, das zwei Closures zwingend verlangt, sodass kein Zweig versehentlich vergessen werden kann.
9Verkompliziert es den Code?
Bei Einzelaufrufen leicht, bei verketteten Schritten meist eine deutliche Vereinfachung gegenüber manuellen Null-Checks.
10Externe Bibliothek nötig?
Nein, wenige readonly-Klassen mit Named Constructors reichen für eine vollständige Eigenimplementierung aus.