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.
| Action | Method | Success Status |
|---|---|---|
| Fetch a list | GET | 200 |
| Fetch one | GET | 200 |
| Create | POST | 201 |
| Full replace | PUT | 200 |
| Partial update | PATCH | 200 |
| Delete | DELETE | 204 (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
messagefor developers and anerrorsmap 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