Promises und Deferred Objects in PHP: asynchrone Werte verwalten
AI generated
<?php
8.4
PHP · Promises · Deferred · Entwurfsmuster
Promises und Deferred Objects in PHP
asynchrone Werte sauber verwalten

Promises kapseln das Ergebnis einer asynchronen Operation, bevor dieses Ergebnis überhaupt vorliegt, während ein Deferred Object die Kontrolle über Auflösung und Ablehnung von außen bereitstellt. Wer die drei Zustände eines Promise, die Regeln der Verkettung und die gängigen Kombinatoren versteht, kann komplexe asynchrone Workflows in PHP strukturieren, egal ob mit ReactPHP, Amp oder einer eigenen minimalen Implementierung.

17 Min. Lesezeit Promise · Deferred · then · Kombinatoren PHP 8.4 · react/promise

1. Was ein Promise wirklich ist

Ein Promise ist ein Objekt, das einen Wert repräsentiert, der jetzt noch nicht existiert, aber irgendwann in der Zukunft verfügbar sein wird, oder eben nie, weil die zugrunde liegende Operation fehlschlägt. Anders als eine gewöhnliche Variable enthält ein Promise zum Zeitpunkt seiner Erzeugung noch keinen Wert, sondern lediglich das Versprechen, dass ein Wert oder ein Fehler zu einem späteren Zeitpunkt bekannt sein wird. Diese einfache Idee löst ein grundlegendes Problem asynchroner Programmierung: Wie übergibt man das Ergebnis einer Operation, die noch nicht abgeschlossen ist?

Vor der Verbreitung von Promises wurde dieses Problem fast ausschließlich mit Callback Funktionen gelöst: eine asynchrone Funktion nimmt eine weitere Funktion als Argument entgegen und ruft diese auf, sobald das Ergebnis vorliegt. Dieser Ansatz funktioniert für einzelne Operationen gut, wird aber unübersichtlich, sobald mehrere asynchrone Schritte nacheinander oder parallel koordiniert werden müssen. Das Promise Muster löst dieses Koordinationsproblem, indem es asynchrone Ergebnisse zu Objekten macht, die sich verketten, kombinieren und weiterreichen lassen wie jeder andere Wert auch.

In PHP wird dieses Muster hauptsächlich durch die Bibliothek react/promise umgesetzt, die eng an die Promises/A Plus Spezifikation aus der JavaScript Welt angelehnt ist. Ein Promise in PHP verhält sich konzeptionell identisch zu seinem JavaScript Gegenstück: Es kapselt einen zukünftigen Wert, bietet die Methode then zur Reaktion auf Erfolg oder Fehlschlag, und lässt sich mit anderen Promises über Kombinatoren wie all oder race koordinieren.

2. Die drei Zustände: pending, fulfilled, rejected

Jedes Promise befindet sich zu jedem Zeitpunkt in genau einem von drei Zuständen. Der Ausgangszustand ist immer pending, ausstehend: Das Ergebnis liegt noch nicht vor, weder ein Erfolgswert noch ein Fehler. Sobald die zugrunde liegende asynchrone Operation erfolgreich abschließt, wechselt das Promise in den Zustand fulfilled, erfüllt, und trägt fortan einen konkreten Wert. Schlägt die Operation stattdessen fehl, wechselt das Promise in den Zustand rejected, abgelehnt, und trägt fortan einen Fehlergrund, meist eine Exception.

Der entscheidende Punkt an diesen drei Zuständen: Ein Promise kann seinen Zustand genau einmal wechseln, von pending zu entweder fulfilled oder rejected, niemals zurück und niemals ein zweites Mal. Diese Unveränderlichkeit nach der ersten Zustandsänderung ist kein technisches Detail, sondern die Grundlage dafür, dass mehrere Codestellen unabhängig voneinander auf dasselbe Promise reagieren können, ohne sich um Race Conditions zwischen mehreren Zustandsänderungen sorgen zu müssen.

Diese Garantie unterscheidet ein Promise fundamental von einem einfachen Callback Register: Ruft man then auf einem bereits fulfilleden Promise auf, wird der übergebene Callback trotzdem garantiert aufgerufen, asynchron im nächsten Tick, mit dem bereits bekannten Wert. Diese Konsistenz zwischen einem noch ausstehenden und einem bereits abgeschlossenen Promise macht das Muster robust gegenüber Timing Problemen, die bei reinen Callback Ansätzen häufig zu subtilen Bugs führen.

3. Deferred Objects: Auflösung von außen kontrollieren

Ein Deferred Object löst ein praktisches Problem bei der Erzeugung von Promises: Ein Promise selbst besitzt bewusst keine öffentlichen Methoden, um seinen eigenen Zustand zu ändern, denn sonst könnte jeder beliebige Code, der Zugriff auf ein Promise hat, dessen Ergebnis manipulieren. Das Deferred Object trennt diese Verantwortung: Es besitzt die Methoden resolve und reject, die den Zustand ändern dürfen, und stellt gleichzeitig über promise() das zugehörige, nur lesbare Promise Objekt bereit.

Diese Trennung erlaubt ein klares Sicherheitsmodell: Der Code, der eine asynchrone Operation tatsächlich durchführt, behält das Deferred Object und ruft irgendwann resolve oder reject darauf auf. Aufrufender Code, der nur auf das Ergebnis warten möchte, bekommt ausschließlich das Promise Objekt zurück und kann dessen Ergebnis niemals selbst erzwingen oder verfälschen. Diese Kapselung verhindert eine ganze Klasse von Bugs, bei denen mehrere Codestellen versehentlich um die Kontrolle über denselben asynchronen Wert konkurrieren.

In der Praxis wird ein Deferred Object fast immer im Innersten einer asynchronen Funktion erzeugt, unmittelbar bevor die eigentliche asynchrone Operation gestartet wird, etwa eine Netzwerkanfrage oder ein Timer. Sobald die Operation abgeschlossen ist, meist innerhalb eines Callbacks der zugrunde liegenden asynchronen API, wird resolve oder reject auf dem Deferred Object aufgerufen, und nur das zugehörige Promise wird nach außen zurückgegeben.


<?php

declare(strict_types=1);

use React\Promise\Deferred;
use React\Promise\PromiseInterface;

function delayedValue(int $milliseconds, mixed $value): PromiseInterface
{
    // The Deferred owns resolve/reject, callers only ever see the Promise
    $deferred = new Deferred();

    $timerId = null;
    $timerId = \React\EventLoop\Loop::addTimer($milliseconds / 1000, function () use ($deferred, $value): void {
        $deferred->resolve($value);
    });

    // Only the read-only promise is exposed to the caller
    return $deferred->promise();
}

delayedValue(500, 'Hello after 500ms')->then(function (string $result): void {
    echo $result . "\n";
});

echo "This line runs before the delayed value resolves\n";

4. Verkettung mit then: Regeln, die überraschen können

Die Methode then ist der zentrale Baustein, um mit Promises zu arbeiten. Sie nimmt bis zu zwei Callbacks entgegen, einen für den Erfolgsfall und einen für den Fehlschlag, und gibt selbst wieder ein neues Promise zurück. Genau diese Rückgabe eines neuen Promise ist die Grundlage für Verkettung: $promise->then($step1)->then($step2)->then($step3) führt drei Schritte nacheinander aus, wobei jeder Schritt den Rückgabewert des vorherigen als Eingabe erhält.

Eine Regel, die häufig überrascht: Gibt ein then Callback selbst wieder ein Promise zurück, statt eines einfachen Werts, wartet die Kette automatisch auf dessen Auflösung, bevor der nächste Schritt ausgeführt wird. Diese automatische Abflachung verschachtelter Promises, im Englischen manchmal Promise Flattening genannt, verhindert, dass man versehentlich ein Promise eines Promise eines Werts erhält, statt direkt des Werts selbst.

Eine zweite Regel betrifft Fehlerbehandlung: Wird in einem then Callback eine Exception geworfen, wechselt das zurückgegebene Promise automatisch in den Zustand rejected, mit der geworfenen Exception als Grund. Das erlaubt es, Fehlerbehandlung an einer einzigen Stelle am Ende der Kette zu bündeln, über einen zweiten then Parameter oder über die Methode otherwise, statt jeden einzelnen Schritt separat abzusichern, ganz ähnlich wie ein try catch Block mehrere synchrone Anweisungen gemeinsam absichert.


<?php

declare(strict_types=1);

use function React\Promise\resolve;

resolve(['id' => 42])
    ->then(function (array $user): array {
        // Returning a value passes it to the next then() step
        $user['name'] = 'Loaded user ' . $user['id'];
        return $user;
    })
    ->then(function (array $user): array {
        if ($user['id'] < 0) {
            // Throwing here rejects the chain, skipping remaining then() steps
            throw new \InvalidArgumentException('Invalid user id');
        }
        $user['validated'] = true;
        return $user;
    })
    ->then(function (array $user): void {
        echo "Final result: {$user['name']}\n";
    })
    ->otherwise(function (\Throwable $error): void {
        // Single place to handle any error from the whole chain
        error_log('Chain failed: ' . $error->getMessage());
    });

5. Kombinatoren: all, race, any und some im Detail

Sobald mehrere Promises gleichzeitig koordiniert werden müssen, kommen Kombinator Funktionen ins Spiel. React\Promise\all nimmt ein Array von Promises entgegen und liefert ein neues Promise, das erfüllt wird, sobald alle übergebenen Promises erfüllt sind, mit einem Array aller Ergebnisse in der ursprünglichen Reihenfolge. Schlägt auch nur eines der übergebenen Promises fehl, wird das zurückgegebene Promise sofort mit demselben Fehler abgelehnt, unabhängig vom Zustand der übrigen.

React\Promise\race verhält sich anders: Es löst sich auf, sobald das allererste der übergebenen Promises einen Zustand erreicht, egal ob erfüllt oder abgelehnt, und übernimmt genau dessen Wert oder Fehler. Dieses Verhalten eignet sich hervorragend für Timeout Muster: ein Promise für die eigentliche Operation gegen ein Promise für einen Timer antreten lassen, sodass eine zu lange dauernde Operation effektiv abgebrochen wird, sobald der Timer zuerst feuert.

React\Promise\any ähnelt race, ignoriert aber Ablehnungen und löst sich erst mit dem ersten tatsächlich erfüllten Promise auf, was sich für redundante, parallele Anfragen an mehrere gleichwertige Server eignet. React\Promise\some geht noch einen Schritt weiter und wartet auf eine konfigurierbare Mindestanzahl erfüllter Promises, bevor es sich selbst auflöst, praktisch für Szenarien, in denen ein Quorum von Antworten genügt, ohne auf jede einzelne Anfrage warten zu müssen.

6. Ein eigenes minimales Promise selbst bauen

Um das Promise Muster wirklich zu verstehen, lohnt es sich, eine minimale Eigenimplementierung zu schreiben, auch wenn man in der Praxis auf ausgereifte Bibliotheken zurückgreifen sollte. Der Kern eines Promise besteht aus einem internen Zustand, einer Liste registrierter Callbacks und der Logik, die diese Callbacks beim Zustandswechsel benachrichtigt. Diese Eigenimplementierung macht sichtbar, warum bestimmte Verhaltensweisen, wie das automatische Auflösen bereits abgeschlossener Promises, überhaupt notwendig sind.

Der wichtigste Designaspekt einer solchen Eigenimplementierung: Callbacks, die per then registriert werden, müssen sowohl im ausstehenden als auch im bereits abgeschlossenen Fall korrekt behandelt werden. Ist das Promise noch pending, wird der Callback in einer internen Liste gespeichert und erst beim späteren Zustandswechsel aufgerufen. Ist das Promise bereits fulfilled oder rejected, muss der Callback sofort, aber typischerweise asynchron im nächsten Tick, mit dem bereits bekannten Ergebnis aufgerufen werden.

Diese Übung zeigt außerdem, warum echte Promise Bibliotheken wie react/promise so viel Sorgfalt auf Exception Handling innerhalb der Callback Ausführung legen: Wirft ein registrierter Callback selbst eine Exception, muss diese abgefangen und in eine Ablehnung des resultierenden verketteten Promise umgewandelt werden, statt den gesamten aufrufenden Code Stack zum Absturz zu bringen.


<?php

declare(strict_types=1);

// Minimal educational Promise implementation, not for production use
final class SimplePromise
{
    private const STATE_PENDING = 'pending';
    private const STATE_FULFILLED = 'fulfilled';
    private const STATE_REJECTED = 'rejected';

    private string $state = self::STATE_PENDING;
    private mixed $value = null;
    /** @var array<int, array{onFulfilled: ?callable, onRejected: ?callable}> */
    private array $callbacks = [];

    public function resolve(mixed $value): void
    {
        if ($this->state !== self::STATE_PENDING) {
            return; // A Promise can only settle once
        }

        $this->state = self::STATE_FULFILLED;
        $this->value = $value;
        $this->notify();
    }

    public function reject(\Throwable $reason): void
    {
        if ($this->state !== self::STATE_PENDING) {
            return;
        }

        $this->state = self::STATE_REJECTED;
        $this->value = $reason;
        $this->notify();
    }

    public function then(?callable $onFulfilled = null, ?callable $onRejected = null): void
    {
        $this->callbacks[] = ['onFulfilled' => $onFulfilled, 'onRejected' => $onRejected];

        // Already settled: notify this new callback immediately
        if ($this->state !== self::STATE_PENDING) {
            $this->notify();
        }
    }

    private function notify(): void
    {
        foreach ($this->callbacks as $callback) {
            if ($this->state === self::STATE_FULFILLED && $callback['onFulfilled'] !== null) {
                ($callback['onFulfilled'])($this->value);
            } elseif ($this->state === self::STATE_REJECTED && $callback['onRejected'] !== null) {
                ($callback['onRejected'])($this->value);
            }
        }

        $this->callbacks = [];
    }
}

7. Häufige Fehler: verschluckte Rejections und vergessenes catch

Der häufigste Fehler im Umgang mit Promises ist eine Ablehnung, die niemals behandelt wird. Wird ein Promise abgelehnt, aber niemand ruft then mit einem Fehler Callback oder otherwise darauf auf, geht der Fehlergrund stillschweigend verloren, ohne dass er irgendwo protokolliert wird. Diese sogenannte unbehandelte Ablehnung ist eine der häufigsten Ursachen für schwer auffindbare Bugs in Promise basiertem Code, weil der Fehler nirgendwo im Log erscheint.

Ein zweiter häufiger Fehler betrifft die Verkettung selbst: Wird in einem then Callback ein neues Promise erzeugt, aber nicht zurückgegeben, wartet die äußere Kette nicht auf dessen Abschluss. Der Code läuft scheinbar korrekt weiter, aber Nebenwirkungen der inneren asynchronen Operation, etwa ein Datenbank Schreibvorgang, sind zum Zeitpunkt des nächsten Kettenglieds möglicherweise noch nicht abgeschlossen. Die Regel lautet deshalb: Jedes innerhalb eines then Callbacks erzeugte Promise muss zurückgegeben werden, damit die Kette korrekt darauf wartet.

Ein dritter Stolperstein: Mischt man Promises aus verschiedenen Bibliotheken, etwa ein react/promise Objekt mit einem Amp\Future, funktionieren Verkettung und Kombinatoren nicht ohne explizite Adapter, weil beide Implementierungen zwar konzeptionell ähnlich, aber technisch unterschiedliche Klassen sind. Für konsistente Codebasen empfiehlt sich, sich auf ein einziges Promise System pro Projekt festzulegen, statt mehrere parallel zu verwenden.

8. Warum Amp v3 auf Future statt Promise setzt

Die Bibliothek Amp verwendete in Version 2 noch klassische Promises nach demselben Muster wie react/promise, wechselte in Version 3 aber vollständig zu Future Objekten, aufbauend auf PHP Fibers. Der Grund liegt in einer fundamentalen Vereinfachung: Ein Future wird nicht über then Callbacks konsumiert, sondern über die Methode await, die den aufrufenden Code innerhalb einer Fiber pausiert, bis das Ergebnis vorliegt, und dann direkt den Wert zurückgibt, wie im vorherigen Artikel über das Async/Await Pattern beschrieben.

Konzeptionell bleibt die Idee identisch zum Promise Muster: ein Objekt, das einen noch nicht vorhandenen Wert repräsentiert, mit denselben drei Zuständen ausstehend, erfüllt und abgelehnt. Der Unterschied liegt allein in der Konsumtions Syntax: then Ketten versus direktes await. Für Entwickler, die von JavaScript kommen, wo sowohl then Ketten als auch async/await auf demselben Promise Objekt existieren, mag diese Aufspaltung in zwei getrennte Bibliotheken zunächst überraschen, ist aber eine bewusste Design Entscheidung von Amp, um Verwirrung zwischen den beiden Konsumtions Stilen zu vermeiden.

Für neue PHP Projekte, die asynchrone I/O benötigen, bedeutet das: react/promise mit klassischen Promises bleibt relevant für Projekte, die auf dem ReactPHP Ökosystem aufbauen, während Amp Nutzer mit Future und await arbeiten. Beide Ansätze lösen dasselbe grundlegende Problem, unterscheiden sich aber in der bevorzugten Syntax für die Konsumtion der Ergebnisse.

9. Promise Muster im direkten Vergleich

Verschiedene Werkzeuge im Promise Ökosystem eignen sich für unterschiedliche Koordinationsprobleme. Die Wahl des richtigen Kombinators hat direkten Einfluss auf Korrektheit und Lesbarkeit des resultierenden Codes.

Anforderung Falscher Ansatz Richtiges Werkzeug Grund
Alle Ergebnisse abwarten Verschachtelte then() Promise\all() Flache, lesbare Struktur
Timeout gegen Operation Manuelles Flag setzen Promise\race() Erstes Ergebnis gewinnt automatisch
Redundante Server abfragen Erste Antwort manuell filtern Promise\any() Ignoriert Fehlschläge automatisch
Fehler an einer Stelle behandeln try/catch in jedem then() otherwise() am Ende Ein zentraler Fehlerpfad
Wert von außen setzen Promise direkt mutieren Deferred Klare Trennung von Lesen und Schreiben

Die Tabelle zeigt, dass für praktisch jedes Koordinationsproblem bereits ein passender Kombinator existiert. Wer diese Werkzeuge kennt, muss selten eigene Synchronisationslogik von Hand schreiben, was die Fehleranfälligkeit von Promise basiertem Code erheblich senkt gegenüber selbstgebauten Lösungen mit manuellen Zählern oder Flags.

Mironsoft

Asynchrone PHP Muster und Promise basierte Architekturen

Asynchrone Workflows, die sich sauber koordinieren lassen?

Wir strukturieren bestehenden Callback Code mit Promises und Deferred Objects, wählen die passenden Kombinatoren für eure Koordinationsprobleme und beraten bei der Wahl zwischen react/promise und Amp Future.

Code-Refactoring

Callback Verschachtelung in saubere Promise Ketten auflösen

Kombinator-Auswahl

all, race, any und some gezielt für eure Koordinationsprobleme einsetzen

Architektur-Beratung

Wahl zwischen react/promise, Amp Future und eigenen Implementierungen

10. Zusammenfassung

Ein Promise kapselt einen asynchronen Wert über drei klar definierte Zustände: ausstehend, erfüllt und abgelehnt, wobei der Wechsel von ausstehend zu einem der beiden Endzustände genau einmal geschieht und niemals rückgängig gemacht wird. Deferred Objects trennen die Kontrolle über diesen Zustandswechsel vom nur lesbaren Promise, das an aufrufenden Code weitergegeben wird, und verhindern damit, dass mehrere Codestellen versehentlich um dieselbe Auflösung konkurrieren.

Verkettung mit then, kombiniert mit Kombinatoren wie all, race, any und some, deckt praktisch jedes Koordinationsproblem zwischen mehreren Promises ab, ohne dass eigene Synchronisationslogik geschrieben werden muss. Wer eine minimale Eigenimplementierung einmal selbst nachbaut, versteht die zugrunde liegenden Regeln tiefer und vermeidet häufige Fehler wie verschluckte Ablehnungen oder vergessene Rückgabewerte in Ketten, unabhängig davon, ob am Ende react/promise oder Amp Futures zum Einsatz kommen.

Promises und Deferred Objects in PHP — Das Wichtigste auf einen Blick

Drei Zustände

Pending, fulfilled, rejected. Wechsel geschieht genau einmal und ist danach unveränderlich.

Deferred

Trennt resolve/reject vom nur lesbaren Promise, das an Aufrufer weitergegeben wird.

Verkettung

then flacht verschachtelte Promises automatisch ab, Exceptions wandern in rejected.

Kombinatoren

all, race, any und some koordinieren mehrere Promises ohne manuelle Zähler.

11. FAQ: Promises und Deferred Objects in PHP

1Was ist ein Promise in PHP?
Ein Objekt für einen noch nicht vorhandenen Wert, der später verfügbar wird oder abgelehnt wird. In PHP hauptsächlich über react/promise umgesetzt.
2Welche Zustände gibt es?
Pending als Ausgangszustand, dann fulfilled oder rejected. Der Wechsel geschieht genau einmal und ist final.
3Wofür ein Deferred Object?
Trennt resolve/reject vom nur lesbaren Promise, das an Aufrufer weitergegeben wird, um Manipulation zu verhindern.
4Was, wenn then() ein Promise zurückgibt?
Die Kette wartet automatisch auf dessen Auflösung, statt ein Promise eines Promises zurückzugeben.
5Unterschied zwischen all und race?
all wartet auf alle und liefert alle Ergebnisse. race löst sich mit dem ersten fertigen Promise auf, egal ob erfolgreich oder fehlgeschlagen.
6Wofür eignet sich any?
Ignoriert Ablehnungen, löst sich mit dem ersten erfüllten Promise auf. Geeignet für redundante Anfragen an mehrere Server.
7Was ist eine unbehandelte Ablehnung?
Eine Ablehnung ohne registrierten Fehler-Callback. Der Fehler geht still verloren, ohne Protokollierung.
8Warum Future statt Promise in Amp v3?
Future wird über await konsumiert, aufbauend auf Fibers. Die Idee bleibt identisch, nur die Syntax unterscheidet sich.
9react/promise und Amp Future mischbar?
Nicht ohne Adapter, da beide unterschiedliche Klassen sind. Ein System pro Projekt ist empfehlenswert.
10Eigene Implementierung in Produktion?
Nein, für Produktion ausgereifte Bibliotheken nutzen. Eigenbau dient nur dem tieferen Verständnis der Mechanik.