REST-API-Design: Versionierung, Auth & Dokumentation
Technologie & Architektur

REST-API-Design: Versionierung, Auth & Dokumentation

Carola Schulte
Invalid Date
18 min

“Wir brauchen eine API für die Mobile-App.” Dieser Satz ist der Anfang vieler Projekte, und leider auch vieler Probleme. Nach 25+ Jahren Softwareentwicklung habe ich APIs gesehen, die nach 2 Wochen nicht mehr wartbar waren, und solche, die seit 10 Jahren stabil laufen. Der Unterschied? Durchdachtes Design von Anfang an.

Executive Summary: Warum API-Design wichtig ist

Real Case: Inventur-System API

  • 15 Mobile-Clients, 3 Web-Frontends, 2 Partner-Integrationen
  • API läuft seit 5 Jahren ohne Breaking Changes
  • 2.5M Requests/Tag, Antwortzeit < 50ms (P95)

Was schiefgehen kann:

  • Versionierung vergessen: Breaking Changes zwingen alle Clients zum Update
  • Keine Rate-Limits: Ein fehlerhafter Client legt das System lahm
  • Fehlende Dokumentation: Entwickler raten, wie die API funktioniert

TL;DR: Die Kernpunkte

  • URL-Design: Ressourcen-orientiert, Plural, konsistente Namenskonvention
  • Versionierung: URI-basiert (/v1/) für Public APIs, Header für interne
  • Authentifizierung: JWT mit kurzer Laufzeit + Refresh-Token
  • Rate-Limiting: Pro API-Key, mit aussagekräftigen Headern
  • Dokumentation: OpenAPI 3.0 (Swagger), automatisch aus Code generiert
  • Fehlerbehandlung: Konsistente JSON-Struktur mit Error-Codes

1. URL-Design: Ressourcen statt Aktionen

Das Problem

# Schlecht: Aktions-orientiert (RPC-Style)
POST /api/getUser
POST /api/createOrder
POST /api/deleteProduct

# Besser: Ressourcen-orientiert (REST)
GET    /api/v1/users/{id}
POST   /api/v1/orders
DELETE /api/v1/products/{id}

Die Regeln

1. Plural für Collections

GET /api/v1/users          # Liste aller User
GET /api/v1/users/123      # Einzelner User
POST /api/v1/users         # Neuen User anlegen

2. Verschachtelte Ressourcen sparsam einsetzen

# OK: Eine Ebene
GET /api/v1/users/123/orders

# Zu tief: Vermeiden
GET /api/v1/users/123/orders/456/items/789/variants
# Besser:
GET /api/v1/order-items/789

3. Konsistente Namenskonvention

# Kebab-Case für URLs
GET /api/v1/order-items
GET /api/v1/user-profiles

# CamelCase für JSON-Properties
{
    "orderId": 123,
    "createdAt": "2025-09-15T10:30:00Z",
    "lineItems": []
}

PHP-Implementierung: Router

class ApiRouter {
    private array $routes = [];

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

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

    public function dispatch(string $method, string $uri): mixed {
        foreach ($this->routes[$method] ?? [] as $pattern => $handler) {
            if ($params = $this->match($pattern, $uri)) {
                return $handler($params);
            }
        }
        throw new NotFoundException('Route not found');
    }

    private function match(string $pattern, string $uri): ?array {
        // /users/{id} -> /users/(\d+)
        $regex = preg_replace('/\{(\w+)\}/', '(?P<$1>[^/]+)', $pattern);
        $regex = '#^' . $regex . '$#';

        if (preg_match($regex, $uri, $matches)) {
            return array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
        }
        return null;
    }
}

// Nutzung
$router = new ApiRouter();

$router->get('/api/v1/users/{id}', function($params) {
    $user = UserRepository::find($params['id']);
    return new JsonResponse($user);
});

$router->post('/api/v1/users', function($params) {
    $data = json_decode(file_get_contents('php://input'), true);
    $user = UserService::create($data);
    return new JsonResponse($user, 201);
});

2. Versionierung: URI vs. Header

Die drei Ansätze

AnsatzBeispielProContra
URI-Pfad/api/v1/usersEinfach, sichtbar, cacheable”Unschön” laut REST-Puristen
Query-Parameter/api/users?version=1FlexibelLeicht zu vergessen
HeaderAccept: application/vnd.api+json;version=1”Richtig” laut RESTKomplizierter, nicht sichtbar

Meine Empfehlung

Für Public APIs: URI-Pfad

/api/v1/users
/api/v2/users

Warum? Entwickler sehen sofort, welche Version sie nutzen. Debugging ist einfacher. Caching funktioniert ohne Konfiguration.

Für interne APIs: Header oder gar keine Versionierung

Bei internen APIs kontrollieren Sie alle Clients. Hier können Sie Breaking Changes koordinieren, ohne mehrere Versionen parallel zu betreiben.

Wann eine neue Version?

Neue Version nötig (Breaking Change):

  • Feld entfernt oder umbenannt
  • Datentyp geändert (String zu Integer)
  • Pflichtfeld hinzugefügt
  • Semantik geändert (gleiches Feld, andere Bedeutung)

Keine neue Version nötig:

  • Neues optionales Feld
  • Neuer Endpoint
  • Bugfix (Verhalten entspricht jetzt der Dokumentation)

PHP: Version-Middleware

class ApiVersionMiddleware {
    private const SUPPORTED_VERSIONS = ['v1', 'v2'];
    private const DEFAULT_VERSION = 'v1';

    public function handle(Request $request, callable $next): Response {
        $version = $this->extractVersion($request);

        if (!in_array($version, self::SUPPORTED_VERSIONS)) {
            return new JsonResponse([
                'error' => 'unsupported_version',
                'message' => 'API version not supported',
                'supported' => self::SUPPORTED_VERSIONS
            ], 400);
        }

        $request->setVersion($version);

        $response = $next($request);
        $response->setHeader('X-API-Version', $version);

        // Deprecation-Warning für alte Versionen
        if ($version === 'v1') {
            $response->setHeader(
                'X-API-Deprecation',
                'v1 will be removed on 2026-01-01. Please migrate to v2.'
            );
        }

        return $response;
    }

    private function extractVersion(Request $request): string {
        // Aus URI: /api/v1/users
        if (preg_match('#/api/(v\d+)/#', $request->getUri(), $matches)) {
            return $matches[1];
        }

        // Aus Header: X-API-Version: v2
        if ($header = $request->getHeader('X-API-Version')) {
            return $header;
        }

        return self::DEFAULT_VERSION;
    }
}

3. Authentifizierung: JWT richtig einsetzen

JWT-Struktur

Header.Payload.Signature

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Die goldenen Regeln

1. Kurze Laufzeit für Access-Tokens

class JwtService {
    private const ACCESS_TOKEN_TTL = 900;     // 15 Minuten
    private const REFRESH_TOKEN_TTL = 604800; // 7 Tage

    public function createAccessToken(User $user): string {
        $payload = [
            'sub' => $user->getId(),
            'email' => $user->getEmail(),
            'roles' => $user->getRoles(),
            'iat' => time(),
            'exp' => time() + self::ACCESS_TOKEN_TTL,
            'type' => 'access'
        ];

        return $this->encode($payload);
    }

    public function createRefreshToken(User $user): string {
        $payload = [
            'sub' => $user->getId(),
            'iat' => time(),
            'exp' => time() + self::REFRESH_TOKEN_TTL,
            'type' => 'refresh',
            'jti' => bin2hex(random_bytes(16)) // Unique ID für Revocation
        ];

        // Refresh-Token in DB speichern für Revocation
        RefreshTokenRepository::store($payload['jti'], $user->getId(), $payload['exp']);

        return $this->encode($payload);
    }
}

2. Refresh-Token-Flow

class AuthController {
    public function login(Request $request): JsonResponse {
        $credentials = $request->json();

        $user = AuthService::authenticate(
            $credentials['email'],
            $credentials['password']
        );

        if (!$user) {
            return new JsonResponse([
                'error' => 'invalid_credentials',
                'message' => 'Email or password incorrect'
            ], 401);
        }

        return new JsonResponse([
            'access_token' => JwtService::createAccessToken($user),
            'refresh_token' => JwtService::createRefreshToken($user),
            'expires_in' => 900,
            'token_type' => 'Bearer'
        ]);
    }

    public function refresh(Request $request): JsonResponse {
        $refreshToken = $request->json()['refresh_token'] ?? null;

        try {
            $payload = JwtService::decode($refreshToken);

            if ($payload['type'] !== 'refresh') {
                throw new InvalidTokenException('Not a refresh token');
            }

            // Prüfen ob Token revoked wurde
            if (RefreshTokenRepository::isRevoked($payload['jti'])) {
                throw new InvalidTokenException('Token revoked');
            }

            $user = UserRepository::find($payload['sub']);

            return new JsonResponse([
                'access_token' => JwtService::createAccessToken($user),
                'expires_in' => 900,
                'token_type' => 'Bearer'
            ]);

        } catch (Exception $e) {
            return new JsonResponse([
                'error' => 'invalid_token',
                'message' => 'Refresh token invalid or expired'
            ], 401);
        }
    }
}

3. API-Keys für Server-zu-Server

class ApiKeyAuthMiddleware {
    public function handle(Request $request, callable $next): Response {
        $apiKey = $request->getHeader('X-API-Key');

        if (!$apiKey) {
            return new JsonResponse([
                'error' => 'missing_api_key',
                'message' => 'X-API-Key header required'
            ], 401);
        }

        // API-Key hashen und in DB suchen
        $hashedKey = hash('sha256', $apiKey);
        $client = ApiClientRepository::findByKeyHash($hashedKey);

        if (!$client || !$client->isActive()) {
            return new JsonResponse([
                'error' => 'invalid_api_key',
                'message' => 'API key invalid or inactive'
            ], 401);
        }

        // Rate-Limit prüfen
        if (RateLimiter::isExceeded($client->getId())) {
            return new JsonResponse([
                'error' => 'rate_limit_exceeded',
                'message' => 'Too many requests'
            ], 429);
        }

        $request->setApiClient($client);
        return $next($request);
    }
}

4. Rate-Limiting: Schutz vor Überlastung

Warum Rate-Limiting?

  • Schutz vor DDoS: Einzelne Clients können System nicht lahmlegen
  • Fair Use: Alle Clients bekommen faire Ressourcen
  • Kostenkontrolle: Bei Cloud-Hosting wichtig

Implementierung mit Token-Bucket

class RateLimiter {
    private const DEFAULT_LIMIT = 1000;      // Requests pro Stunde
    private const DEFAULT_BURST = 50;        // Max. Burst

    public function check(string $identifier): RateLimitResult {
        $key = 'ratelimit:' . $identifier;
        $now = microtime(true);

        // Redis-basierter Token-Bucket
        $bucket = $this->redis->hGetAll($key);

        if (empty($bucket)) {
            // Neuer Bucket
            $bucket = [
                'tokens' => self::DEFAULT_BURST,
                'last_update' => $now,
                'limit' => self::DEFAULT_LIMIT
            ];
        }

        // Tokens auffüllen basierend auf vergangener Zeit
        $elapsed = $now - $bucket['last_update'];
        $refillRate = $bucket['limit'] / 3600; // Tokens pro Sekunde
        $newTokens = min(
            self::DEFAULT_BURST,
            $bucket['tokens'] + ($elapsed * $refillRate)
        );

        if ($newTokens < 1) {
            return new RateLimitResult(
                allowed: false,
                remaining: 0,
                resetAt: $now + ((1 - $newTokens) / $refillRate)
            );
        }

        // Token verbrauchen
        $bucket['tokens'] = $newTokens - 1;
        $bucket['last_update'] = $now;

        $this->redis->hMSet($key, $bucket);
        $this->redis->expire($key, 7200);

        return new RateLimitResult(
            allowed: true,
            remaining: (int)$bucket['tokens'],
            resetAt: $now + 3600
        );
    }
}

Response-Header für Rate-Limits

class RateLimitMiddleware {
    public function handle(Request $request, callable $next): Response {
        $identifier = $request->getApiClient()?->getId()
            ?? $request->getClientIp();

        $result = RateLimiter::check($identifier);

        $response = $result->allowed
            ? $next($request)
            : new JsonResponse([
                'error' => 'rate_limit_exceeded',
                'message' => 'Too many requests, please retry later',
                'retry_after' => ceil($result->resetAt - time())
            ], 429);

        // Immer Rate-Limit-Header setzen
        $response->setHeaders([
            'X-RateLimit-Limit' => '1000',
            'X-RateLimit-Remaining' => (string)$result->remaining,
            'X-RateLimit-Reset' => (string)ceil($result->resetAt),
        ]);

        if (!$result->allowed) {
            $response->setHeader('Retry-After', ceil($result->resetAt - time()));
        }

        return $response;
    }
}

5. Fehlerbehandlung: Konsistente Struktur

Das Problem

// Inkonsistent: Mal so...
{"error": "User not found"}

// ...mal so...
{"success": false, "msg": "Invalid input"}

// ...mal so
{"status": "error", "data": null, "errors": ["Field required"]}

Die Lösung: RFC 7807 Problem Details

class ApiException extends Exception {
    public function __construct(
        private string $type,
        private string $title,
        string $detail,
        private int $status,
        private array $extensions = []
    ) {
        parent::__construct($detail, $status);
    }

    public function toArray(): array {
        return array_merge([
            'type' => $this->type,
            'title' => $this->title,
            'status' => $this->status,
            'detail' => $this->getMessage(),
        ], $this->extensions);
    }
}

// Vordefinierte Exceptions
class ValidationException extends ApiException {
    public function __construct(array $errors) {
        parent::__construct(
            type: 'https://api.example.com/errors/validation',
            title: 'Validation Error',
            detail: 'One or more fields failed validation',
            status: 422,
            extensions: ['errors' => $errors]
        );
    }
}

class NotFoundException extends ApiException {
    public function __construct(string $resource, string|int $id) {
        parent::__construct(
            type: 'https://api.example.com/errors/not-found',
            title: 'Resource Not Found',
            detail: "{$resource} with ID {$id} not found",
            status: 404,
            extensions: ['resource' => $resource, 'id' => $id]
        );
    }
}

Beispiel-Responses

// 422 Validation Error
{
    "type": "https://api.example.com/errors/validation",
    "title": "Validation Error",
    "status": 422,
    "detail": "One or more fields failed validation",
    "errors": {
        "email": ["Invalid email format"],
        "password": ["Must be at least 8 characters"]
    }
}

// 404 Not Found
{
    "type": "https://api.example.com/errors/not-found",
    "title": "Resource Not Found",
    "status": 404,
    "detail": "User with ID 999 not found",
    "resource": "User",
    "id": 999
}

// 429 Rate Limit
{
    "type": "https://api.example.com/errors/rate-limit",
    "title": "Rate Limit Exceeded",
    "status": 429,
    "detail": "Too many requests, please retry later",
    "retry_after": 120
}

6. Dokumentation: OpenAPI 3.0

Warum OpenAPI?

  • Maschinenlesbar: Code-Generatoren für Client-SDKs
  • Menschenlesbar: Swagger UI für Entwickler
  • Testbar: Automatische Request-Validation
  • Standardisiert: Tooling-Ökosystem vorhanden

Beispiel: OpenAPI-Spec

openapi: 3.0.3
info:
  title: Business API
  version: 1.0.0
  description: API für Geschäftsanwendungen

servers:
  - url: https://api.example.com/v1
    description: Production
  - url: https://api.staging.example.com/v1
    description: Staging

security:
  - bearerAuth: []

paths:
  /users:
    get:
      summary: Liste aller User
      tags: [Users]
      parameters:
        - name: page
          in: query
          schema:
            type: integer
            default: 1
        - name: per_page
          in: query
          schema:
            type: integer
            default: 20
            maximum: 100
      responses:
        '200':
          description: Erfolgreiche Antwort
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/User'
                  meta:
                    $ref: '#/components/schemas/PaginationMeta'

    post:
      summary: Neuen User anlegen
      tags: [Users]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUserRequest'
      responses:
        '201':
          description: User erstellt
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '422':
          $ref: '#/components/responses/ValidationError'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  schemas:
    User:
      type: object
      properties:
        id:
          type: integer
          example: 123
        email:
          type: string
          format: email
          example: user@example.com
        name:
          type: string
          example: Max Mustermann
        created_at:
          type: string
          format: date-time

    CreateUserRequest:
      type: object
      required: [email, password, name]
      properties:
        email:
          type: string
          format: email
        password:
          type: string
          minLength: 8
        name:
          type: string
          minLength: 2

    PaginationMeta:
      type: object
      properties:
        current_page:
          type: integer
        per_page:
          type: integer
        total:
          type: integer
        total_pages:
          type: integer

  responses:
    ValidationError:
      description: Validierungsfehler
      content:
        application/json:
          schema:
            type: object
            properties:
              type:
                type: string
              title:
                type: string
              status:
                type: integer
              detail:
                type: string
              errors:
                type: object

PHP: Spec aus Code generieren

#[Attribute(Attribute::TARGET_METHOD)]
class ApiEndpoint {
    public function __construct(
        public string $summary,
        public string $description = '',
        public array $tags = [],
        public int $successStatus = 200
    ) {}
}

#[Attribute(Attribute::TARGET_PARAMETER)]
class QueryParam {
    public function __construct(
        public string $name,
        public string $type = 'string',
        public bool $required = false,
        public mixed $default = null
    ) {}
}

// Nutzung
class UserController {
    #[ApiEndpoint(
        summary: 'Liste aller User',
        tags: ['Users']
    )]
    public function index(
        #[QueryParam('page', 'integer', default: 1)] int $page,
        #[QueryParam('per_page', 'integer', default: 20)] int $perPage
    ): JsonResponse {
        // ...
    }
}

// Generator liest Attributes und erzeugt OpenAPI-Spec
$generator = new OpenApiGenerator();
$spec = $generator->generate(UserController::class);

7. Security Deep Dive: Die häufigsten Fehler

SQL-Injection verhindern

Das Problem: Dynamische Queries mit User-Input.

// FALSCH: SQL-Injection möglich
$query = "SELECT * FROM users WHERE email = '{$_GET['email']}'";

// RICHTIG: Prepared Statements (PDO)
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = :email");
$stmt->execute(['email' => $request->get('email')]);

Bei dynamischen WHERE-Clauses:

class QueryBuilder {
    private array $conditions = [];
    private array $params = [];

    public function where(string $field, string $operator, mixed $value): self {
        // Whitelist für erlaubte Felder
        $allowedFields = ['status', 'created_at', 'user_id'];
        if (!in_array($field, $allowedFields)) {
            throw new InvalidArgumentException("Field not allowed: {$field}");
        }

        // Whitelist für Operatoren
        $allowedOperators = ['=', '!=', '>', '<', '>=', '<=', 'LIKE'];
        if (!in_array($operator, $allowedOperators)) {
            throw new InvalidArgumentException("Operator not allowed: {$operator}");
        }

        $paramName = 'p' . count($this->params);
        $this->conditions[] = "{$field} {$operator} :{$paramName}";
        $this->params[$paramName] = $value;

        return $this;
    }

    public function build(): array {
        return [
            'sql' => implode(' AND ', $this->conditions),
            'params' => $this->params
        ];
    }
}

CSRF bei JWT-APIs: Braucht man das?

Kurze Antwort: Bei reinen API-Tokens (Authorization Header) nicht, bei Cookies schon.

SzenarioCSRF-Schutz nötig?
JWT im Authorization HeaderNein (Browser sendet Header nicht automatisch)
JWT im HttpOnly CookieJa (Cookie wird automatisch gesendet)
Session-basierte AuthJa

Wenn JWT im Cookie:

class CsrfMiddleware {
    public function handle(Request $request, callable $next): Response {
        // Nur für state-changing Methoden
        if (in_array($request->getMethod(), ['GET', 'HEAD', 'OPTIONS'])) {
            return $next($request);
        }

        $tokenFromHeader = $request->getHeader('X-CSRF-Token');
        $tokenFromCookie = $request->getCookie('csrf_token');

        if (!$tokenFromHeader || !hash_equals($tokenFromCookie, $tokenFromHeader)) {
            return new JsonResponse([
                'error' => 'csrf_validation_failed',
                'message' => 'Invalid or missing CSRF token'
            ], 403);
        }

        return $next($request);
    }
}

CORS richtig konfigurieren

Development vs. Production:

class CorsMiddleware {
    private array $config;

    public function __construct() {
        $this->config = match(getenv('APP_ENV')) {
            'production' => [
                'allowed_origins' => [
                    'https://app.example.com',
                    'https://admin.example.com'
                ],
                'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE'],
                'allowed_headers' => ['Content-Type', 'Authorization', 'X-API-Key'],
                'max_age' => 86400,
                'credentials' => true
            ],
            default => [
                'allowed_origins' => ['*'],
                'allowed_methods' => ['*'],
                'allowed_headers' => ['*'],
                'max_age' => 0,
                'credentials' => false
            ]
        };
    }

    public function handle(Request $request, callable $next): Response {
        // Preflight Request
        if ($request->getMethod() === 'OPTIONS') {
            return $this->preflightResponse($request);
        }

        $response = $next($request);
        return $this->addCorsHeaders($response, $request);
    }

    private function addCorsHeaders(Response $response, Request $request): Response {
        $origin = $request->getHeader('Origin');

        // Origin prüfen
        if ($this->isAllowedOrigin($origin)) {
            $response->setHeader('Access-Control-Allow-Origin', $origin);
        }

        if ($this->config['credentials']) {
            $response->setHeader('Access-Control-Allow-Credentials', 'true');
        }

        return $response;
    }

    private function isAllowedOrigin(?string $origin): bool {
        if ($origin === null) return false;
        if (in_array('*', $this->config['allowed_origins'])) return true;
        return in_array($origin, $this->config['allowed_origins']);
    }
}

Häufige CORS-Fehler:

FehlerProblemLösung
Access-Control-Allow-Origin: * mit CredentialsBrowser blockiertExplizite Origin angeben
Fehlende Preflight-ResponseOPTIONS-Requests schlagen fehlOPTIONS-Handler implementieren
Wildcard bei Headern in ProductionSicherheitsrisikoExplizite Header-Whitelist

8. Performance Best Practices

Response-Zeiten unter 50ms: Wie?

1. Database Connection Pooling

class DatabasePool {
    private static array $connections = [];
    private static int $maxConnections = 10;

    public static function getConnection(): PDO {
        // Bestehende Verbindung wiederverwenden
        foreach (self::$connections as $key => $conn) {
            if ($conn['in_use'] === false) {
                self::$connections[$key]['in_use'] = true;
                return $conn['pdo'];
            }
        }

        // Neue Verbindung erstellen (wenn unter Limit)
        if (count(self::$connections) < self::$maxConnections) {
            $pdo = new PDO(
                getenv('DATABASE_DSN'),
                getenv('DATABASE_USER'),
                getenv('DATABASE_PASSWORD'),
                [
                    PDO::ATTR_PERSISTENT => true,
                    PDO::ATTR_EMULATE_PREPARES => false
                ]
            );

            self::$connections[] = ['pdo' => $pdo, 'in_use' => true];
            return $pdo;
        }

        throw new RuntimeException('Connection pool exhausted');
    }

    public static function release(PDO $pdo): void {
        foreach (self::$connections as $key => $conn) {
            if ($conn['pdo'] === $pdo) {
                self::$connections[$key]['in_use'] = false;
                return;
            }
        }
    }
}

2. Das N+1-Problem lösen

// SCHLECHT: N+1 Queries
$users = UserRepository::findAll(); // 1 Query
foreach ($users as $user) {
    $orders = OrderRepository::findByUserId($user->id); // N Queries
}

// BESSER: Eager Loading
class UserRepository {
    public function findAllWithOrders(): array {
        $sql = "
            SELECT u.*, o.id as order_id, o.total, o.created_at as order_date
            FROM users u
            LEFT JOIN orders o ON o.user_id = u.id
            ORDER BY u.id, o.created_at DESC
        ";

        $rows = $this->pdo->query($sql)->fetchAll();

        // Gruppieren nach User
        $users = [];
        foreach ($rows as $row) {
            $userId = $row['id'];
            if (!isset($users[$userId])) {
                $users[$userId] = [
                    'id' => $row['id'],
                    'email' => $row['email'],
                    'name' => $row['name'],
                    'orders' => []
                ];
            }
            if ($row['order_id']) {
                $users[$userId]['orders'][] = [
                    'id' => $row['order_id'],
                    'total' => $row['total'],
                    'date' => $row['order_date']
                ];
            }
        }

        return array_values($users);
    }
}

3. Caching-Strategien

class CachedUserRepository {
    private const CACHE_TTL = 300; // 5 Minuten

    public function __construct(
        private UserRepository $repository,
        private Redis $redis
    ) {}

    public function find(int $id): ?array {
        $cacheKey = "user:{$id}";

        // Cache-Hit?
        $cached = $this->redis->get($cacheKey);
        if ($cached !== false) {
            return json_decode($cached, true);
        }

        // Cache-Miss: DB-Query
        $user = $this->repository->find($id);

        if ($user) {
            $this->redis->setex($cacheKey, self::CACHE_TTL, json_encode($user));
        }

        return $user;
    }

    public function invalidate(int $id): void {
        $this->redis->del("user:{$id}");
    }
}

HTTP-Caching für GET-Requests:

class HttpCacheMiddleware {
    public function handle(Request $request, callable $next): Response {
        if ($request->getMethod() !== 'GET') {
            return $next($request);
        }

        $response = $next($request);

        // ETag generieren
        $etag = md5($response->getBody());
        $response->setHeader('ETag', '"' . $etag . '"');

        // Client hat ETag geschickt?
        $clientEtag = $request->getHeader('If-None-Match');
        if ($clientEtag === '"' . $etag . '"') {
            return new Response('', 304); // Not Modified
        }

        // Cache-Control für öffentliche Ressourcen
        if ($this->isPublicResource($request)) {
            $response->setHeader('Cache-Control', 'public, max-age=300');
        } else {
            $response->setHeader('Cache-Control', 'private, no-cache');
        }

        return $response;
    }
}

Performance-Checkliste

OptimierungImpactAufwand
Prepared StatementsMittelNiedrig
Connection PoolingHochMittel
N+1 eliminierenSehr hochMittel
Redis-CacheHochMittel
HTTP-Caching (ETag)MittelNiedrig
PaginationHochNiedrig

9. Testing-Strategie für APIs

Drei Ebenen der API-Tests

┌─────────────────────────────────────┐
│         Contract Tests              │  Stimmt API mit Spec überein?
│         (Dredd, Pact)               │
├─────────────────────────────────────┤
│       Integration Tests             │  Funktionieren Endpoints?
│       (PHPUnit, Pest)               │
├─────────────────────────────────────┤
│         Load Tests                  │  Hält API der Last stand?
│         (k6, Gatling)               │
└─────────────────────────────────────┘

1. Contract Tests mit Dredd

OpenAPI-Spec als Vertrag:

# Installation
npm install -g dredd

# Test gegen lokale API
dredd openapi.yaml http://localhost:8080

dredd.yml Konfiguration:

dry-run: false
hookfiles:
  - ./test/hooks.php
language: php
server: php -S localhost:8080 -t public
server-wait: 3
endpoint: http://localhost:8080

Hooks für Auth:

// test/hooks.php
use Dredd\Hooks;

Hooks::beforeAll(function(&$transaction) {
    // Auth-Token für alle Requests
    $token = getTestAuthToken();
    $transaction['request']['headers']['Authorization'] = "Bearer {$token}";
});

Hooks::beforeEach(function(&$transaction) {
    // Test-Daten in DB anlegen
    setupTestFixtures();
});

Hooks::afterEach(function(&$transaction) {
    // Cleanup
    cleanupTestData();
});

2. Integration Tests mit PHPUnit

class UserApiTest extends ApiTestCase {
    public function test_create_user_returns_201(): void {
        $response = $this->postJson('/api/v1/users', [
            'email' => 'test@example.com',
            'password' => 'SecurePass123!',
            'name' => 'Test User'
        ]);

        $response->assertStatus(201);
        $response->assertJsonStructure([
            'id',
            'email',
            'name',
            'created_at'
        ]);

        // Prüfen, dass User in DB existiert
        $this->assertDatabaseHas('users', [
            'email' => 'test@example.com'
        ]);
    }

    public function test_create_user_validates_email(): void {
        $response = $this->postJson('/api/v1/users', [
            'email' => 'invalid-email',
            'password' => 'SecurePass123!',
            'name' => 'Test User'
        ]);

        $response->assertStatus(422);
        $response->assertJsonPath('errors.email.0', 'Invalid email format');
    }

    public function test_get_user_requires_authentication(): void {
        $response = $this->getJson('/api/v1/users/1');

        $response->assertStatus(401);
    }

    public function test_rate_limit_returns_429(): void {
        // 1001 Requests senden (Limit: 1000/h)
        for ($i = 0; $i < 1001; $i++) {
            $response = $this->getJson('/api/v1/users');
        }

        $response->assertStatus(429);
        $response->assertHeader('Retry-After');
    }
}

3. Load Tests mit k6

// load-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
    stages: [
        { duration: '30s', target: 50 },   // Ramp-up auf 50 User
        { duration: '1m', target: 50 },    // 1 Minute halten
        { duration: '30s', target: 100 },  // Auf 100 erhöhen
        { duration: '1m', target: 100 },   // 1 Minute halten
        { duration: '30s', target: 0 },    // Ramp-down
    ],
    thresholds: {
        http_req_duration: ['p(95)<200'],  // 95% unter 200ms
        http_req_failed: ['rate<0.01'],    // Fehlerrate unter 1%
    },
};

const BASE_URL = __ENV.API_URL || 'https://api.example.com';
const AUTH_TOKEN = __ENV.AUTH_TOKEN;

export default function() {
    const headers = {
        'Authorization': `Bearer ${AUTH_TOKEN}`,
        'Content-Type': 'application/json',
    };

    // GET /users
    const listResponse = http.get(`${BASE_URL}/v1/users?page=1`, { headers });
    check(listResponse, {
        'list status 200': (r) => r.status === 200,
        'list has data': (r) => JSON.parse(r.body).data.length > 0,
    });

    sleep(1);

    // POST /users
    const createResponse = http.post(`${BASE_URL}/v1/users`, JSON.stringify({
        email: `test-${Date.now()}@example.com`,
        password: 'TestPass123!',
        name: 'Load Test User'
    }), { headers });

    check(createResponse, {
        'create status 201': (r) => r.status === 201,
    });

    sleep(1);
}

Ausführung:

# Lokal
k6 run load-test.js

# Mit Umgebungsvariablen
k6 run -e API_URL=https://api.staging.example.com -e AUTH_TOKEN=xxx load-test.js

# HTML-Report generieren
k6 run --out json=results.json load-test.js

Test-Matrix

Test-TypWann ausführenToolDauer
Unit TestsBei jedem CommitPHPUnit< 1 min
Integration TestsBei jedem PRPHPUnit2-5 min
Contract TestsNightly / vor ReleaseDredd5-10 min
Load TestsWöchentlich / vor Releasek610-30 min

10. Checkliste: API-Design Review

Vor dem Start

  • Ressourcen definiert: Welche Entitäten hat die API?
  • Versionierungsstrategie: URI, Header oder keine?
  • Auth-Methode: JWT, API-Key, OAuth?
  • Rate-Limits: Pro Client, pro Endpoint?

URL-Design

  • Ressourcen-orientiert (nicht aktions-orientiert)
  • Plural für Collections
  • Konsistente Namenskonvention (kebab-case)
  • Max. 2 Ebenen Verschachtelung

Sicherheit

  • HTTPS only (kein HTTP)
  • JWT mit kurzer Laufzeit (< 15 min)
  • Refresh-Token mit Revocation
  • Rate-Limiting implementiert
  • Input-Validation auf allen Endpoints

Responses

  • Konsistente Fehlerstruktur (RFC 7807)
  • Passende HTTP-Statuscodes
  • Pagination für Listen
  • Rate-Limit-Header in allen Responses

Dokumentation

  • OpenAPI 3.0 Spec vorhanden
  • Swagger UI deployed
  • Beispiel-Requests für alle Endpoints
  • Changelog für Versionen

Fazit: Gute APIs sind langweilig

Die besten APIs sind die, über die niemand redet. Sie funktionieren einfach. Entwickler finden intuitiv, was sie suchen. Fehler sind verständlich. Dokumentation ist aktuell.

Die wichtigsten Prinzipien:

  1. Konsistenz über Cleverness: Lieber langweilig und vorhersagbar als kreativ und überraschend
  2. Versionierung von Anfang an: Nachträglich einführen ist schmerzhaft
  3. Dokumentation ist Teil des Produkts: Undokumentierte Features existieren nicht
  4. Fehler sind Features: Gute Fehlermeldungen sparen Support-Tickets

Sie planen eine API?

In einem kostenlosen Erstgespräch analysiere ich Ihre Anforderungen:

  • Welche Clients werden die API nutzen?
  • Interne API oder Public API?
  • Welche Authentifizierung passt?
  • Wie sieht die Versionierungsstrategie aus?

📧 E-Mail: die@entwicklerin.net 🌐 Website: www.entwicklerin.net

Keine Buzzwords, nur pragmatische Lösungen aus 25+ Jahren Praxis.


Über die Autorin: Carola Schulte entwickelt seit 25+ Jahren Web-APIs für Business-Anwendungen. Von internen Microservices bis zu Public APIs mit tausenden Entwicklern. Spezialisiert auf PHP, PostgreSQL und pragmatisches API-Design ohne Over-Engineering.

Carola Schulte

Über Carola Schulte

Software-Architektin mit 25+ Jahren Erfahrung. Spezialisiert auf robuste Business-Apps mit PHP/PostgreSQL, Security-by-Design und DSGVO-konforme Systeme. 1,8M+ Lines of Code in Produktion.

Projekt im Kopf?

Lassen Sie uns besprechen, wie ich Ihre Anforderungen umsetzen kann – kostenlos und unverbindlich.

Kostenloses Erstgespräch