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](/playground) to experiment further
- Check out the [PHP Manual](https://www.php.net/manual/) for reference

Course complete!

You've reached the end of the curriculum. Review any lesson or try the playground.

Back to all lessons