Skip to lesson

learningphp.org / advanced / 25-rest-api-best-practices · lesson 25 of 25

TL;DR

Design production-grade REST APIs in PHP with consistent response structures, proper HTTP semantics, error handling, and versioning.

Key concepts

  • PHP REST API best practices
  • API design patterns
  • REST API versioning
  • API error handling PHP

Rest Api Best Practices

A REST API is a contract between your server and every client that depends on it. Break the contract — wrong status codes, inconsistent response shapes, vague error messages — and you create bugs that are painful to debug on both ends. Good REST design is not about following rules for their own sake; it is about making your API predictable so clients never have to guess.

This lesson covers the patterns that separate a production-grade API from a rough prototype: consistent response structures, correct HTTP semantics, structured error handling, input validation, and versioning.

Consistent Response Envelopes

The single biggest improvement you can make to a REST API is a consistent response shape. Every response — success or failure — should have the same top-level keys. Clients can then write one response parser instead of handling each endpoint differently.

A minimal envelope includes a success flag, the payload under data, and optional metadata:

<?php
function apiResponse(int $status, mixed $data, ?string $message = null, array $meta = []): string {
    $body = [
        'success' => $status >= 200 && $status < 300,
        'status'  => $status,
        'data'    => $data,
    ];

    if ($message !== null) {
        $body['message'] = $message;
    }

    if (!empty($meta)) {
        $body['meta'] = $meta;
    }

    return json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
}

// 200 — resource found
echo apiResponse(200, ['id' => 42, 'name' => 'Alice', 'email' => 'alice@example.com']);

echo "\n\n";

// 201 — resource created
echo apiResponse(201, ['id' => 43], 'User created successfully');

echo "\n\n";

// 404 — not found
echo apiResponse(404, null, 'User not found');

echo "\n\n";

// 200 with pagination meta
$users = [
    ['id' => 1, 'name' => 'Alice'],
    ['id' => 2, 'name' => 'Bob'],
];
echo apiResponse(200, $users, null, ['page' => 1, 'per_page' => 10, 'total' => 2]);

Notice that even a 404 uses the same shape. Clients check success, not the HTTP status code alone.

HTTP Methods and Status Codes

REST uses HTTP verbs to express intent. Using the wrong verb — a POST for a read, a GET that mutates data — makes your API unpredictable and breaks HTTP caching.

ActionMethodSuccess Status
Fetch a listGET200
Fetch oneGET200
CreatePOST201
Full replacePUT200
Partial updatePATCH200
DeleteDELETE204 (no body)

The following example simulates a simple router dispatching to the right handler based on method and path:

<?php
class FakeRequest {
    public function __construct(
        public readonly string $method,
        public readonly string $path,
        public readonly array  $body = [],
    ) {}
}

$store = [
    1 => ['id' => 1, 'title' => 'Learn PHP', 'done' => false],
    2 => ['id' => 2, 'title' => 'Build an API', 'done' => false],
];

function dispatch(FakeRequest $req, array &$store): string {
    $parts  = explode('/', trim($req->path, '/'));
    $id     = isset($parts[2]) ? (int)$parts[2] : null;

    return match ([$req->method, isset($id)]) {
        ['GET',    false] => json_encode(['success' => true, 'data' => array_values($store)], JSON_PRETTY_PRINT),
        ['GET',    true]  => isset($store[$id])
            ? json_encode(['success' => true, 'data' => $store[$id]], JSON_PRETTY_PRINT)
            : json_encode(['success' => false, 'status' => 404, 'message' => 'Not found']),
        ['POST',   false] => (function () use ($req, &$store) {
            $newId          = max(array_keys($store)) + 1;
            $store[$newId]  = ['id' => $newId] + $req->body;
            return json_encode(['success' => true, 'status' => 201, 'data' => $store[$newId]], JSON_PRETTY_PRINT);
        })(),
        ['DELETE', true] => (function () use ($id, &$store) {
            if (!isset($store[$id])) {
                return json_encode(['success' => false, 'status' => 404, 'message' => 'Not found']);
            }
            unset($store[$id]);
            return json_encode(['success' => true, 'status' => 204, 'data' => null]);
        })(),
        default => json_encode(['success' => false, 'status' => 405, 'message' => 'Method not allowed']),
    };
}

echo "GET /api/todos\n";
echo dispatch(new FakeRequest('GET', '/api/todos'), $store) . "\n\n";

echo "POST /api/todos\n";
echo dispatch(new FakeRequest('POST', '/api/todos', ['title' => 'Write tests', 'done' => false]), $store) . "\n\n";

echo "GET /api/todos/3\n";
echo dispatch(new FakeRequest('GET', '/api/todos/3'), $store) . "\n\n";

echo "DELETE /api/todos/1\n";
echo dispatch(new FakeRequest('DELETE', '/api/todos/1'), $store) . "\n";

Structured Error Handling

Errors need as much care as successful responses. A vague {"error": "bad request"} forces clients to log the raw request and guess what went wrong. A structured error tells the client exactly which field failed and why.

<?php
class ApiException extends RuntimeException {
    public function __construct(
        string         $message,
        int            $code     = 400,
        public readonly array $errors = [],
    ) {
        parent::__construct($message, $code);
    }

    public function toArray(): array {
        return [
            'success' => false,
            'status'  => $this->code,
            'message' => $this->message,
            'errors'  => $this->errors,
        ];
    }
}

function validateUser(array $input): array {
    $errors = [];

    if (empty($input['name'])) {
        $errors['name'] = 'Name is required';
    } elseif (strlen($input['name']) < 2) {
        $errors['name'] = 'Name must be at least 2 characters';
    }

    if (empty($input['email'])) {
        $errors['email'] = 'Email is required';
    } elseif (!filter_var($input['email'], FILTER_VALIDATE_EMAIL)) {
        $errors['email'] = 'Email must be a valid address';
    }

    if (empty($input['age']) || !is_int($input['age']) || $input['age'] < 0) {
        $errors['age'] = 'Age must be a non-negative integer';
    }

    if (!empty($errors)) {
        throw new ApiException('Validation failed', 422, $errors);
    }

    return $input;
}

$cases = [
    ['name' => '', 'email' => 'not-an-email', 'age' => -5],
    ['name' => 'Alice', 'email' => 'alice@example.com', 'age' => 30],
];

foreach ($cases as $input) {
    try {
        $validated = validateUser($input);
        echo json_encode(['success' => true, 'data' => $validated], JSON_PRETTY_PRINT);
    } catch (ApiException $e) {
        echo json_encode($e->toArray(), JSON_PRETTY_PRINT);
    }
    echo "\n\n";
}

The 422 Unprocessable Entity status code is the correct choice for validation failures — the request was well-formed, but the data did not pass business rules.

API Versioning

APIs evolve. If you change a response shape without versioning, you break every existing client. The most common versioning strategy is a URL prefix: /api/v1/, /api/v2/. This keeps both versions running side by side until clients migrate.

A lightweight version-aware dispatcher shows the pattern:

<?php
function routeVersion(string $path, string $method): string {
    preg_match('#^/api/(v\d+)/(\w+)(?:/(\d+))?$#', $path, $m);

    $version  = $m[1] ?? 'v1';
    $resource = $m[2] ?? '';
    $id       = $m[3] ?? null;

    $handlers = [
        'v1' => [
            'users' => fn($id) => $id
                ? ['id' => (int)$id, 'name' => 'Alice']
                : [['id' => 1, 'name' => 'Alice']],
        ],
        'v2' => [
            'users' => fn($id) => $id
                ? ['id' => (int)$id, 'full_name' => 'Alice Smith', 'avatar_url' => 'https://example.com/alice.jpg']
                : [['id' => 1, 'full_name' => 'Alice Smith', 'avatar_url' => 'https://example.com/alice.jpg']],
        ],
    ];

    if (!isset($handlers[$version][$resource])) {
        return json_encode(['success' => false, 'status' => 404, 'message' => "No $version endpoint for $resource"]);
    }

    $data = ($handlers[$version][$resource])($id);
    return json_encode(['success' => true, 'version' => $version, 'data' => $data], JSON_PRETTY_PRINT);
}

echo "v1 user list:\n" . routeVersion('/api/v1/users', 'GET') . "\n\n";
echo "v2 user list:\n" . routeVersion('/api/v2/users', 'GET') . "\n\n";
echo "v1 single user:\n" . routeVersion('/api/v1/users/1', 'GET') . "\n\n";
echo "unknown version:\n" . routeVersion('/api/v3/users', 'GET') . "\n";

Try It Yourself

Extend the todo API below to support a PATCH endpoint that updates only the fields provided in the request body. A PATCH to /api/todos/1 with {"done": true} should set that todo's done field without touching title.

<?php
class FakeRequest {
    public function __construct(
        public readonly string $method,
        public readonly string $path,
        public readonly array  $body = [],
    ) {}
}

$store = [
    1 => ['id' => 1, 'title' => 'Learn PHP', 'done' => false],
    2 => ['id' => 2, 'title' => 'Build an API', 'done' => false],
];

function handle(FakeRequest $req, array &$store): string {
    preg_match('#^/api/todos(?:/(\d+))?$#', $req->path, $m);
    $id = isset($m[1]) ? (int)$m[1] : null;

    if ($req->method === 'GET' && $id === null) {
        return json_encode(['success' => true, 'data' => array_values($store)], JSON_PRETTY_PRINT);
    }

    if ($req->method === 'GET' && $id !== null) {
        return isset($store[$id])
            ? json_encode(['success' => true, 'data' => $store[$id]], JSON_PRETTY_PRINT)
            : json_encode(['success' => false, 'status' => 404, 'message' => 'Not found']);
    }

    // TODO: add PATCH handler here
    // Merge $req->body into $store[$id] only for keys that exist

    return json_encode(['success' => false, 'status' => 405, 'message' => 'Method not allowed']);
}

// Test your PATCH handler:
echo handle(new FakeRequest('PATCH', '/api/todos/1', ['done' => true]), $store) . "\n\n";
echo handle(new FakeRequest('GET', '/api/todos/1'), $store) . "\n";

Key Takeaways

  • Consistent envelopes — every response shares the same top-level shape so clients write one parser, not many
  • Correct HTTP verbs — GET reads, POST creates, PUT replaces, PATCH partially updates, DELETE removes; mixing these up breaks caching and client assumptions
  • Meaningful status codes — 201 for created, 204 for no content, 422 for validation failures, 404 for missing resources; avoid returning 200 for everything
  • Structured errors — include a message for developers and an errors map for field-level failures so clients can display useful feedback
  • URL versioning — prefix routes with /v1/ so breaking changes can ship under /v2/ while existing clients stay on the old contract
  • Resource nouns, not verbs/api/users, not /api/getUsers; the HTTP method provides the verb

Pro Tip: Treat your own API like a third-party dependency. Write a small client in plain PHP that consumes every endpoint you ship — if the client feels awkward or needs special-case logic to parse responses, the API design needs fixing before you ship it, not after.

Course Complete!

Congratulations — you've completed the Learning PHP curriculum! You now have solid skills in PHP web development, from basics to modern patterns.

What to do next:

  • Build a project using Laravel or a custom API
  • Explore the PHP Playground to experiment further
  • Check out the PHP Manual for reference

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.

Course complete!

You've reached the end of the curriculum. Keep practicing to sharpen what you've learned.

Keep practicing