GraphQL was supposed to kill REST. It did not. What killed REST was developers who used it wrong. APIs that return 200 OK with an error in the body. Endpoints called /api/getUserData that use POST. Resources nested eleven levels deep. REST is not the problem. Bad REST is the problem.
A good API is honest. It says what it does, does what it says, and fails loudly when it cannot.
HTTP status codes are not decorative. 200 means success. 201 means created. 204 means no content. 400 means the client sent bad data. 401 means unauthenticated. 403 means unauthorized. 404 means the resource does not exist. 409 means conflict. 422 means validation failed. 500 means the server broke. Use the right code. Every single time.
<?php
// Honest HTTP responses
class ApiResponse
{
public static function ok(mixed $data): Response
{
return new Response(200, json_encode($data));
}
public static function created(string $location, mixed $data): Response
{
return new Response(201, json_encode($data), ['Location' => $location]);
}
public static function noContent(): Response
{
return new Response(204);
}
public static function validationFailed(array $errors): Response
{
return new Response(422, json_encode([
'error' => 'Validation failed',
'details' => $errors
]));
}
public static function notFound(string $resource): Response
{
return new Response(404, json_encode([
'error' => "{$resource} not found"
]));
}
public static function conflict(string $message): Response
{
return new Response(409, json_encode([
'error' => $message
]));
}
}Resource naming is where most APIs fail before they even handle a request. Use nouns, not verbs. /users, not /getUsers. /posts/42/comments, not /getCommentsForPost. Keep nesting shallow. Three levels is the practical maximum. Beyond that, use query parameters or flatten the hierarchy.
GET /posts # List posts (paginated)
GET /posts?author=dev # Filter by author
GET /posts/42 # Get single post
POST /posts # Create post
PATCH /posts/42 # Partial update
PUT /posts/42 # Full replacement
DELETE /posts/42 # Delete post
GET /posts/42/comments # List comments for post
POST /posts/42/comments # Create comment on post
# WRONG:
GET /api/getPostById?id=42
POST /api/updatePost
GET /api/posts/42/comments/7/replies/3/likesPagination is not optional. Every list endpoint must support it. Cursor-based pagination is superior to offset-based for large datasets. Offsets become slow as tables grow because the database must scan and discard rows. Cursors use indexed values to jump directly to the right position. They are also stable — inserting a new row does not shift the entire result set.
<?php
// Cursor-based pagination
function listPosts(?string $cursor, int $limit = 20): array
{
$query = 'SELECT * FROM posts WHERE published = 1';
$params = [];
if ($cursor) {
$decoded = base64_decode($cursor);
[$lastId, $lastDate] = explode(':', $decoded);
$query .= ' AND (created_at < ? OR (created_at = ? AND id < ?))';
$params = [$lastDate, $lastDate, $lastId];
}
$query .= ' ORDER BY created_at DESC, id DESC LIMIT ?';
$params[] = $limit + 1; // +1 to detect next page
$stmt = $this->db->prepare($query);
$stmt->execute($params);
$rows = $stmt->fetchAll();
$hasNext = count($rows) > $limit;
$posts = array_slice($rows, 0, $limit);
$nextCursor = $hasNext
? base64_encode($posts[count($posts) - 1]['id'] . ':' . $posts[count($posts) - 1]['created_at'])
: null;
return ['data' => $posts, 'next_cursor' => $nextCursor];
}Version your API from day one. Not in the URL — /v2/users is ugly and breaks hypermedia. Use the Accept header. Accept: application/vnd.api+json;version=2. This keeps URLs stable while allowing evolution. When you need to break compatibility, clients opt into the new version explicitly. No surprise failures. No forced migrations.
A good API is a contract. It makes promises and keeps them. It fails predictably. It evolves without breaking. REST is not dead. REST done right is the most reliable architecture for web APIs. The problem was never REST. The problem was developers who treated HTTP like a transport layer instead of the application protocol it is.
Comments