Designing the Endpoints
Resources as URLs with the verb in the method, pagination that does not drift, versioning, and what breaks a consumer.
GET /v1/articles?limit=20&after=14Once another program depends on your API, the shape of it is a commitment. These are the conventions that make it predictable and let you change it later.
Nouns in the path, verbs in the method
A URL names a thing: /v1/articles for the collection, /v1/articles/14 for one of them. What you do to it lives in the HTTP method, not in the path, so /getArticle?id=14 is a path doing the method's job. GET reads, POST creates, PUT replaces, PATCH changes part, DELETE removes. GET and HEAD must never change anything, because crawlers, caches and retry logic all assume they are free to call them again.
Pagination
Never return a whole table. Offset pagination (?page=3&per_page=20) is easy to write and drifts: insert a row while a client is paging and it sees one item twice. Keyset pagination passes the last id seen (?limit=20&after=14) and stays stable. Cap the limit on the server whichever you choose.
Versioning
Put the version in the path, as /v1/. It shows up in logs and in the browser, so you can see who is still on the old one.
What breaks a consumer
Removing or renaming a field, changing its type, making an optional input required, narrowing validation or changing a status code all break working code. Adding a field, adding an optional parameter or adding an endpoint do not, provided consumers ignore fields they do not recognise. Write that expectation into your documentation, then keep to it.
Example
When to use it
- Lay out the endpoints for a new service before writing any of the handlers.
- Replace an offset list that returns duplicates when records are added mid-page.
- Decide whether a requested field rename needs a v2 or can ship on Friday.
- Document for a partner exactly which future changes they must tolerate.
More examples
The two pagination queries
Both forms as SQL, both with the user's numbers bound rather than pasted into the string. Support offset while consumers need it, and offer keyset as the one you recommend. The integer binds are not optional: PDO quotes bound values as strings by default, and connections with emulated prepares turn an unbound LIMIT placeholder into a syntax error that appears on the server and not on your machine.
<?php
declare(strict_types=1);
$limit = min(100, max(1, (int) ($_GET['limit'] ?? 20)));
// Offset: simple, and the database still counts past every skipped row,
// so page 500 is slow. An insert between calls shifts every later page.
if (isset($_GET['page'])) {
$offset = (max(1, (int) $_GET['page']) - 1) * $limit;
$stmt = $pdo->prepare(
'SELECT id, title FROM articles ORDER BY id LIMIT :limit OFFSET :offset'
);
// LIMIT and OFFSET must be bound as integers. With emulated prepares a
// string bind produces LIMIT '20', which is a syntax error.
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
} else {
// Keyset: the index on id does the skipping, so page 500 costs the same
// as page 1, and the window never shifts.
$after = (int) ($_GET['after'] ?? 0);
$stmt = $pdo->prepare(
'SELECT id, title FROM articles WHERE id > :after ORDER BY id LIMIT :limit'
);
$stmt->bindValue(':after', $after, PDO::PARAM_INT);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
}
$stmt->execute();
$rows = $stmt->fetchAll();
A route table instead of a chain of ifs
A table makes the whole API readable at a glance and keeps the same URL in one place for all its methods. It also gives you the 404-versus-405 distinction for free: collect the methods that matched the path, and if any did, the path exists and the verb was wrong. Hand-written if chains almost always answer 404 there, which sends the consumer looking for a typo in the URL.
<?php
declare(strict_types=1);
// The path arrives already stripped of the query string by parse_url.
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$method = $_SERVER['REQUEST_METHOD'];
$routes = [
['GET', '#^/v1/articles$#', 'listArticles'],
['POST', '#^/v1/articles$#', 'createArticle'],
['GET', '#^/v1/articles/(\d+)$#', 'showArticle'],
['PATCH', '#^/v1/articles/(\d+)$#', 'updateArticle'],
['DELETE', '#^/v1/articles/(\d+)$#', 'deleteArticle'],
];
$pathMatched = [];
foreach ($routes as [$routeMethod, $pattern, $handler]) {
if (preg_match($pattern, $path, $m) !== 1) {
continue;
}
$pathMatched[] = $routeMethod;
if ($routeMethod === $method) {
$handler(...array_slice($m, 1)); // the captured id, if any
exit;
}
}
// The URL exists but not with this verb: that is 405, not 404.
if ($pathMatched !== []) {
header('Allow: ' . implode(', ', $pathMatched));
http_response_code(405);
exit;
}
http_response_code(404);
Adding to a response without breaking it
Keep this comparison next to your changelog. The rule that makes the left column possible is stated in your own documentation: consumers must ignore fields they do not recognise. Say it in writing, because a client that validates against a strict schema will break on an added field and will blame you. When a change does belong in the right column, a new version is cheaper than the support load of silently breaking people.
<?php
declare(strict_types=1);
// v1, shipped last year. Consumers read $response['data'][n]['title'].
$v1 = [
'data' => [
['id' => 14, 'title' => 'Escaping output', 'published_at' => '2026-02-01'],
],
'meta' => ['next_cursor' => null],
];
// Safe to add to v1: a new field, and new meta. Nothing moved or changed type.
$v1Later = [
'data' => [
['id' => 14, 'title' => 'Escaping output', 'published_at' => '2026-02-01',
'reading_minutes' => 4],
],
'meta' => ['next_cursor' => null, 'total' => 1],
];
// NOT safe in v1. Every line here breaks working client code:
// 'id' => '14' type changed from number to string
// 'headline' => 'Escaping output' title renamed
// 'published_at' removed field deleted
// 'data' => [ ... ] flattened structure changed
// Changes like these need /v2/, with /v1/ kept running and a date
// published for when it stops.
Discussion