RESTful API Design in PHP: Ressourcen, Verben und Statuscodes
AI generated
<?php
8.4
PHP · API Design · REST · Backend
RESTful API Design in PHP
Ressourcen, Verben und Statuscodes statt RPC im REST-Kostüm

Viele PHP-APIs nennen sich REST, sind aber in Wahrheit RPC-Aufrufe mit HTTP-Verpackung: ein POST-Endpunkt für jede Aktion, immer Statuscode 200, egal was passiert. Echtes RESTful API Design in PHP verlangt Ressourcen statt Aktionen, korrekt eingesetzte HTTP-Verben, durchdachte Statuscodes und Idempotenz, wo sie erwartet wird, und das lässt sich ohne ein einziges Framework sauber umsetzen.

18 Min. Lesezeit Ressourcen · Verben · Statuscodes · Idempotenz PHP 8.4 · framework-unabhängig

1. Was RESTful API Design in PHP wirklich bedeutet

RESTful API Design ist keine Frage des Frameworks, sondern eine Frage der Modellierung: Wird die API um Ressourcen herum aufgebaut, die man abfragt, erstellt, ändert und löscht, oder um Aktionen herum, die man aufruft. Viele PHP-Projekte, die "REST-API" im Namen tragen, sind bei näherem Hinsehen eine Sammlung von POST-Endpunkten wie /getUser, /updateOrder und /deleteInvoice. Das funktioniert technisch, ist aber kein RESTful API Design, sondern RPC mit HTTP als Transportschicht.

Der Unterschied ist mehr als Kosmetik. Ein sauberes RESTful API Design nutzt die Semantik von HTTP selbst als Vertragssprache: die URL beschreibt die Ressource, das Verb beschreibt die Operation, der Statuscode beschreibt das Ergebnis. Das reduziert die Dokumentationslast, weil Clients aus der URL-Struktur und dem Verb bereits ableiten können, was passiert, und es macht Caching, Proxies und generische HTTP-Tooling wieder nutzbar, statt jede Semantik in den Response-Body zu verlagern.

2. Ressourcen statt Aktionen: URL-Design richtig strukturieren

Der erste Schritt bei jedem RESTful API Design ist, Substantive statt Verben in die URL zu schreiben. Statt /createOrder und /cancelOrder gibt es genau eine Ressource /orders, und die Operation ergibt sich aus dem HTTP-Verb plus, bei Statusänderungen, aus dem Request-Body oder einer Unterressource wie /orders/{id}/cancellation. Diese Denkweise zwingt zu einer klareren Domänenmodellierung, weil man vorher entscheiden muss, was überhaupt eine Ressource ist und was nur eine Eigenschaft einer Ressource.

Verschachtelte Ressourcen wie /orders/{id}/items sind sinnvoll, wenn die Kindressource ohne die Elternressource keinen eigenständigen Sinn ergibt. Sobald aber Items auch unabhängig abgefragt werden, etwa über eine globale Suche, lohnt sich zusätzlich ein flacher Endpunkt /items?order_id={id}. Ein häufiger Fehler in PHP-Projekten ist, Filterlogik als eigene Aktion zu modellieren, etwa /orders/search. Besser ist ein Query-Parameter auf der bestehenden Kollektion, weil das RESTful API Design dadurch konsistent bleibt und keine Sonderfälle für Suche einführt.


<?php

declare(strict_types=1);

// Minimal router that maps HTTP verb + resource path
// to a handler, without any framework dependency.
final class ResourceRouter
{
    /** @var array<string, array<string, callable>> */
    private array $routes = [];

    public function map(string $method, string $pattern, callable $handler): void
    {
        $this->routes[$method][$pattern] = $handler;
    }

    public function dispatch(string $method, string $path): mixed
    {
        foreach ($this->routes[$method] ?? [] as $pattern => $handler) {
            if (preg_match($this->toRegex($pattern), $path, $matches)) {
                return $handler(...array_filter($matches, is_string(...), ARRAY_FILTER_USE_KEY));
            }
        }
        http_response_code(404);
        return ['error' => 'Resource not found'];
    }

    private function toRegex(string $pattern): string
    {
        $escaped = preg_replace('#\{(\w+)\}#', '(?P<$1>[^/]+)', $pattern);
        return '#^' . $escaped . '$#';
    }
}

$router = new ResourceRouter();

// Resources, not actions — nouns in the URL, verbs via HTTP method
$router->map('GET', '/orders', fn () => OrderRepository::all());
$router->map('GET', '/orders/{id}', fn (string $id) => OrderRepository::find($id));
$router->map('POST', '/orders', fn () => OrderRepository::create());
$router->map('PATCH', '/orders/{id}', fn (string $id) => OrderRepository::update($id));
$router->map('DELETE', '/orders/{id}', fn (string $id) => OrderRepository::delete($id));

3. HTTP-Verben korrekt einsetzen: GET, POST, PUT, PATCH, DELETE

Jedes HTTP-Verb hat im RESTful API Design eine feste Bedeutung, die viele PHP-Endpunkte ignorieren. GET darf niemals einen Zustand verändern, auch nicht als Nebeneffekt eines Logging-Aufrufs mit Schreibzugriff auf eine Datenbank, weil Proxies und Browser GET-Requests wiederholen, cachen und vorab laden dürfen. POST erstellt eine neue Ressource innerhalb einer Kollektion und ist bewusst nicht idempotent. PUT ersetzt eine Ressource vollständig unter einer bekannten URL, während PATCH nur die im Request angegebenen Felder ändert, was in der Praxis der häufigere und oft der einzig sinnvolle Anwendungsfall ist.

DELETE entfernt eine Ressource und sollte, wie PUT, idempotent auf denselben Endzustand konvergieren: Ein zweiter DELETE-Aufruf auf dieselbe ID darf technisch mit 404 statt 204 antworten, aber er darf keinen Fehlerzustand im System hinterlassen. Ein verbreiteter Fehler in PHP-Codebasen ist, PUT für Teil-Updates zu missbrauchen, weil PATCH historisch seltener implementiert wurde. Für ein konsistentes RESTful API Design lohnt sich die Disziplin, PATCH für Teilupdates konsequent zu unterstützen, auch wenn der Aufwand zunächst höher wirkt als ein pauschales PUT.

4. Statuscodes bewusst wählen statt immer 200

Ein API, die bei jedem Request, egal ob erfolgreich, validiert oder abgelehnt, mit Statuscode 200 antwortet und den eigentlichen Zustand im JSON-Body versteckt, verschenkt einen der wichtigsten Bestandteile von HTTP. Statuscodes sind Teil des Vertrags, den ein RESTful API Design mit seinen Konsumenten eingeht: 201 Created nach erfolgreichem POST mit einem Location-Header auf die neue Ressource, 204 No Content nach erfolgreichem DELETE oder PUT ohne Response-Body, 404 Not Found, wenn die Ressource nicht existiert.

Für Validierungsfehler ist 422 Unprocessable Entity semantisch treffender als das oft genutzte 400 Bad Request, weil 400 eigentlich für syntaktisch fehlerhafte Requests reserviert ist, während 422 einen syntaktisch korrekten, aber fachlich ungültigen Request beschreibt. 409 Conflict passt bei Ressourcenkonflikten, etwa bei optimistischem Locking. Wer diese Unterscheidungen im RESTful API Design konsequent durchzieht, ermöglicht es Client-Bibliotheken, generisch auf HTTP-Ebene zu reagieren, statt jeden Response-Body einzeln parsen zu müssen.


<?php

declare(strict_types=1);

// Status codes as part of the contract, not an afterthought
final class OrderController
{
    public function create(array $payload): void
    {
        $errors = OrderValidator::validate($payload);
        if ($errors !== []) {
            http_response_code(422);
            header('Content-Type: application/json');
            echo json_encode(['errors' => $errors]);
            return;
        }

        $order = OrderRepository::create($payload);

        http_response_code(201);
        header('Content-Type: application/json');
        header("Location: /orders/{$order->id}");
        echo json_encode($order);
    }

    public function delete(string $id): void
    {
        $existed = OrderRepository::delete($id);

        // Idempotent: repeated DELETE on the same id never errors the system state
        http_response_code($existed ? 204 : 404);
    }

    public function update(string $id, array $payload): void
    {
        $order = OrderRepository::find($id);
        if ($order === null) {
            http_response_code(404);
            return;
        }

        $updated = OrderRepository::patch($id, $payload);
        http_response_code(200);
        header('Content-Type: application/json');
        echo json_encode($updated);
    }
}

5. Idempotenz verstehen und in PHP sicherstellen

Idempotenz bedeutet, dass ein identischer Request, egal wie oft er wiederholt wird, immer zum selben Endzustand führt. Für ein robustes RESTful API Design ist das entscheidend, weil Netzwerke unzuverlässig sind: Ein Client, der auf eine Antwort wartet und einen Timeout erhält, weiß nicht, ob der Request den Server erreicht hat. Bei idempotenten Verben wie PUT, DELETE und GET ist ein erneuter Versuch gefahrlos. Bei POST, das per Definition nicht idempotent ist, entsteht ohne zusätzliche Absicherung das Risiko doppelter Bestellungen oder doppelter Zahlungen.

Die verbreitete Lösung ist ein Idempotency-Key-Header, den der Client generiert und bei Wiederholungsversuchen unverändert mitsendet. Der Server speichert das Ergebnis des ersten Requests unter diesem Schlüssel, meist mit kurzer Time-to-Live, und liefert bei identischem Schlüssel die gespeicherte Antwort zurück, ohne die Operation ein zweites Mal auszuführen. Diese Technik gehört inzwischen zum Standardrepertoire eines belastbaren RESTful API Designs, besonders bei zahlungsrelevanten oder bestandsverändernden Endpunkten, wo doppelte Ausführung echten finanziellen Schaden verursachen kann.


<?php

declare(strict_types=1);

// Idempotency-Key pattern for otherwise non-idempotent POST requests
final class IdempotentPostHandler
{
    public function __construct(private readonly IdempotencyStore $store) {}

    public function handle(string $idempotencyKey, callable $operation): array
    {
        $cached = $this->store->get($idempotencyKey);
        if ($cached !== null) {
            // Same key seen before — return the stored result, do not repeat the write
            return $cached;
        }

        $result = $operation();
        $this->store->put($idempotencyKey, $result, ttlSeconds: 86400);

        return $result;
    }
}

// Usage in a payment endpoint
$key = $_SERVER['HTTP_IDEMPOTENCY_KEY'] ?? throw new InvalidArgumentException(
    'Idempotency-Key header required for payment operations'
);

$handler = new IdempotentPostHandler(new RedisIdempotencyStore());
$result = $handler->handle($key, fn () => PaymentService::charge($payload));

6. Content-Negotiation: Accept-Header und Response-Format

Content-Negotiation lässt Client und Server über den Accept-Header aushandeln, in welchem Format eine Antwort geliefert wird. In vielen PHP-APIs ist ausschließlich JSON hart verdrahtet, was im internen Umfeld selten ein Problem ist, aber bei öffentlichen APIs Flexibilität kostet, etwa wenn ein Konsument CSV-Exporte oder XML für Legacy-Systeme benötigt. Ein sauberes RESTful API Design behandelt das Format als Verhandlungssache zwischen den Parteien, nicht als feste Eigenschaft des Endpunkts.

Die Umsetzung in PHP ist unaufwendig: Der Accept-Header wird geparst, gegen eine Liste unterstützter Medientypen abgeglichen, und bei fehlender Übereinstimmung liefert der Server 406 Not Acceptable statt stillschweigend ein Format zu erzwingen, das der Client nicht verarbeiten kann. Auch die Content-Type-Angabe im Request selbst gehört zur Content-Negotiation: Ein Server, der nur application/json akzeptiert, sollte bei anderen Werten mit 415 Unsupported Media Type reagieren, statt zu versuchen, den Body zu erraten.


<?php

declare(strict_types=1);

// Simple content negotiation without any framework
final class ContentNegotiator
{
    /** @param string[] $supported */
    public function __construct(private readonly array $supported) {}

    public function resolve(string $acceptHeader): string
    {
        $accepted = array_map(
            fn (string $part) => trim(explode(';', $part)[0]),
            explode(',', $acceptHeader)
        );

        foreach ($accepted as $type) {
            if ($type === '*/*') {
                return $this->supported[0];
            }
            if (in_array($type, $this->supported, true)) {
                return $type;
            }
        }

        http_response_code(406);
        header('Content-Type: application/json');
        echo json_encode(['error' => 'None of the requested media types are supported']);
        exit;
    }
}

$negotiator = new ContentNegotiator(['application/json', 'application/xml', 'text/csv']);
$format = $negotiator->resolve($_SERVER['HTTP_ACCEPT'] ?? 'application/json');

7. Request-Validierung und DTOs ohne Framework

Ein solides RESTful API Design trennt Validierung strikt von Geschäftslogik. Rohe $_POST-Arrays oder per json_decode gewonnene assoziative Arrays direkt an die Domänenschicht weiterzureichen führt zu Controllern, die Validierungsregeln über mehrere Methoden verstreuen. Ein Data Transfer Object, das den validierten Request abbildet, macht die erwartete Form explizit und nutzt PHPs Typsystem, um viele Fehlerklassen bereits beim Konstruieren auszuschließen.

In PHP 8.4 lässt sich das ohne jedes Framework mit readonly-Klassen und einer schlanken Validierungsschicht umsetzen. Der Controller nimmt das rohe Array entgegen, übergibt es an einen Validator, und erst nach erfolgreicher Validierung entsteht das DTO, das dann typsicher an die Domänenschicht weitergereicht wird. Dieser Ansatz macht das RESTful API Design robuster gegenüber unvollständigen oder falsch typisierten Requests, ohne dass man dafür ein komplettes Validierungs-Framework einbinden muss.


<?php

declare(strict_types=1);

// Readonly DTO plus a minimal validator, no framework dependency
final readonly class CreateOrderRequest
{
    public function __construct(
        public string $customerId,
        public array $items,
        public string $currency,
    ) {}

    public static function fromArray(array $data): self
    {
        $errors = [];

        if (!isset($data['customer_id']) || !is_string($data['customer_id'])) {
            $errors['customer_id'] = 'must be a string';
        }
        if (!isset($data['items']) || !is_array($data['items']) || $data['items'] === []) {
            $errors['items'] = 'must be a non-empty array';
        }
        if (!isset($data['currency']) || !in_array($data['currency'], ['EUR', 'USD'], true)) {
            $errors['currency'] = 'must be EUR or USD';
        }

        if ($errors !== []) {
            throw new ValidationException($errors);
        }

        return new self($data['customer_id'], $data['items'], $data['currency']);
    }
}

// Controller stays thin: parse, validate, delegate
$dto = CreateOrderRequest::fromArray(json_decode(file_get_contents('php://input'), true));

8. Response-Strukturen konsistent halten

Ob eine erfolgreiche Antwort in ein Envelope-Objekt mit data-Schlüssel verpackt wird oder die Ressource direkt als Wurzelobjekt liefert, ist im RESTful API Design eine Grundsatzentscheidung, die für die gesamte API einheitlich getroffen werden muss. Ein Envelope wie {"data": {...}, "meta": {...}} erleichtert das Anhängen von Paginierungsinformationen und Metadaten, ohne die eigentliche Ressource zu verunreinigen. Die direkte Variante ist minimalistischer und entspricht eher dem, was viele HTTP-Clients ohne Zusatzlogik erwarten.

Was in jedem Fall vermieden werden sollte, ist eine Mischung beider Stile innerhalb derselben API. Wenn ein Endpunkt die Ressource direkt liefert und ein anderer sie in einen Envelope verpackt, muss jeder Client-Entwickler pro Endpunkt nachschlagen, wie der Response aussieht. Für Kollektionen empfiehlt sich fast immer ein Envelope, weil dort ohnehin Paginierungsmetadaten wie total, page und next mitgeliefert werden müssen. Für Einzelressourcen kann die direkte Variante gewählt werden, solange das RESTful API Design diese Regel dokumentiert und konsequent durchhält.

9. RESTful API Design im direkten Vergleich

Die folgende Tabelle stellt typische Antipatterns den empfohlenen Lösungen im RESTful API Design gegenüber. Sie fasst zusammen, was in den vorherigen Abschnitten im Detail behandelt wurde, und dient als schnelle Referenz bei Code-Reviews.

Aspekt Antipattern RESTful API Design Vorteil
URL-Struktur /getOrder?id=5 GET /orders/5 Cachebar, selbsterklärend, HTTP-Tooling nutzbar
Statuscode 200 + {"success": false} 422 + Fehlerliste Client kann auf HTTP-Ebene reagieren
Teil-Update PUT mit Teildaten PATCH mit Teildaten Semantisch korrekt, verhindert Datenverlust
Doppelter POST Keine Absicherung Idempotency-Key-Header Keine doppelten Bestellungen bei Retries
Löschen POST /orders/5/delete DELETE /orders/5 Nutzt HTTP-Semantik statt Aktions-URL

Ein wiederkehrendes Muster in der Tabelle: Fast jedes Antipattern entsteht, weil eine Aktion statt einer Ressource in den Mittelpunkt gestellt wurde. Wer sein RESTful API Design konsequent von der Ressource her denkt, statt von der auszuführenden Funktion, landet fast automatisch bei den empfohlenen Lösungen der rechten Spalte.

Mironsoft

PHP-Backend-Entwicklung und API-Architektur

Eine API, die wirklich RESTful ist, statt es nur zu behaupten?

Wir überprüfen bestehende PHP-APIs auf Ressourcenmodellierung, Statuscodes und Idempotenz und entwerfen neue Endpunkte nach echten REST-Prinzipien statt nach RPC-Gewohnheit.

API-Review

Bestehende Endpunkte auf RESTful API Design und Statuscode-Nutzung prüfen

Neuentwurf

Ressourcenmodellierung, Idempotenz und Content-Negotiation von Grund auf

Migration

Schrittweise Umstellung von RPC-artigen Endpunkten auf REST

10. Zusammenfassung

RESTful API Design in PHP ist kein Framework-Feature, sondern eine Modellierungsentscheidung, die vor der ersten Zeile Code getroffen wird: Ressourcen statt Aktionen, HTTP-Verben mit ihrer echten Bedeutung, Statuscodes als Teil des Vertrags und Idempotenz dort, wo Netzwerke unzuverlässig sind. Jeder der neun Abschnitte oben zeigt einen Baustein, der für sich genommen einfach umzusetzen ist, aber in der Summe den Unterschied zwischen einer API, die sich wie REST anfühlt, und einer, die nur RPC mit HTTP-Tapete ist, ausmacht.

Der pragmatischste Startpunkt für ein besseres RESTful API Design ist meist die URL-Struktur, gefolgt von den Statuscodes, weil beide ohne größeren Umbau der Geschäftslogik geändert werden können. Idempotenz-Keys und Content-Negotiation lassen sich schrittweise ergänzen, sobald das Grundgerüst steht. Wichtig ist, diese Prinzipien konsequent über alle Endpunkte hinweg durchzuziehen, statt sie punktuell nur für neue Features anzuwenden, während alte Endpunkte im RPC-Stil verharren.

RESTful API Design in PHP — Das Wichtigste auf einen Blick

Ressourcen & Verben

Substantive in der URL, Operation über GET/POST/PUT/PATCH/DELETE. Keine Aktions-URLs wie /deleteOrder.

Statuscodes

201, 204, 404, 409, 422 statt pauschal 200. Statuscode ist Teil des Vertrags, nicht Beiwerk.

Idempotenz

Idempotency-Key-Header für POST verhindert doppelte Bestellungen und Zahlungen bei Retries.

Konsistenz

Envelope oder Direct-Response, einmal entschieden und über die gesamte API hinweg beibehalten.

11. FAQ: RESTful API Design in PHP

1RESTful API Design vs. einfache HTTP-API?
RESTful API Design nutzt HTTP-Semantik selbst als Vertrag: Ressourcen in der URL, Bedeutung im Verb, Ergebnis im Statuscode, statt HTTP nur als Transportweg zu missbrauchen.
2Braucht man ein Framework dafür?
Nein, reines PHP mit einfachem Router und dem Typsystem reicht aus. Frameworks erleichtern Routing, sind aber keine Voraussetzung.
3Warum 422 statt 400?
400 gehört zu syntaktisch fehlerhaften Requests, 422 zu syntaktisch korrekten, aber fachlich ungültigen Requests, was bei Validierung der zutreffende Fall ist.
4PUT oder PATCH?
PUT ersetzt vollständig, PATCH ändert nur angegebene Felder. Für Teil-Updates ist PATCH die korrekte und sicherere Wahl.
5Doppelte Bestellungen bei Timeouts verhindern?
Idempotency-Key-Header vom Client generieren lassen, Ergebnis serverseitig speichern und bei Wiederholung ausliefern statt die Operation erneut auszuführen.
6Muss jede Antwort in einen Envelope?
Nicht zwingend, aber die Wahl muss über die gesamte API konsistent sein. Kollektionen profitieren meist von einem Envelope mit Metadaten.
7Content-Negotiation ohne Framework?
Accept-Header parsen, gegen unterstützte Medientypen abgleichen, bei fehlender Übereinstimmung mit 406 antworten.
8Darf GET wirklich nie Zustand ändern?
Korrekt, weil Proxies und Browser GET-Requests wiederholen und cachen dürfen. Zustandsänderungen gehören ausschließlich zu POST, PUT, PATCH und DELETE.
9Wie Suche modellieren?
Als Query-Parameter auf der bestehenden Kollektion statt als eigene Aktion, das hält die URL-Struktur konsistent.
10Nicht unterstützter Content-Type im Request?
Statuscode 415 Unsupported Media Type verwenden, statt den Body zu erraten oder stillschweigend zu ignorieren.