Chapter 21: Building APIs
APIs let your PHP code communicate predictable facts to clients over HTTP. This chapter focuses on building small and reliable web APIs with plain PHP; you will see resource-oriented routes, correct status codes, JSON responses, security considerations, throttling, and lightweight documentation. You will also get a short introduction to GraphQL using community packages.
RESTful Design with Plain PHP
RESTful design treats your API as a collection of resources that are addressed by URLs and manipulated with HTTP methods like GET, POST, PUT, and DELETE. A small front controller can route requests, validate input, call domain code, then emit an HTTP response with a body and headers.
Creating a tiny front controller for routing
A single index.php can parse the path and method; then dispatch to handlers in a simple map. Keep handlers pure where possible and return arrays for easy JSON encoding.
<?php
// public/index.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$routes = [
'GET' => [
'#^/v1/articles$#' => 'listArticles',
'#^/v1/articles/(\d+)$#' => 'getArticle',
],
'POST' => [
'#^/v1/articles$#' => 'createArticle',
],
'PUT' => [
'#^/v1/articles/(\d+)$#' => 'updateArticle',
],
'DELETE' => [
'#^/v1/articles/(\d+)$#' => 'deleteArticle',
],
];
function respond(int $status, array $body = [], array $headers = []): void {
http_response_code($status);
foreach ($headers as $h => $v) header($h . ': ' . $v, true);
echo json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
// Parse JSON safely
function jsonInput(): array {
$raw = file_get_contents('php://input') ?: '';
$data = json_decode($raw, true);
if (json_last_error() !== JSON_ERROR_NONE) {
respond(400, ['error' => 'Invalid JSON']);
}
return is_array($data) ? $data : [];
}
// Handlers
function listArticles(): array {
return ['data' => [['id' => 1, 'title' => 'Hello'], ['id' => 2, 'title' => 'World']]];
}
function getArticle(int $id): array {
if ($id !== 1) respond(404, ['error' => 'Not found']);
return ['data' => ['id' => 1, 'title' => 'Hello']];
}
function createArticle(): array {
$payload = jsonInput();
if (!isset($payload['title']) || $payload['title'] === '') {
respond(422, ['error' => 'Title is required']);
}
return ['data' => ['id' => 3, 'title' => $payload['title']]];
}
function updateArticle(int $id): array {
$payload = jsonInput();
return ['data' => ['id' => $id, 'title' => $payload['title'] ?? 'Untitled']];
}
function deleteArticle(int $id): array {
return ['meta' => ['deleted' => $id]];
}
// Dispatch
foreach ($routes[$method] ?? [] as $pattern => $handler) {
if (preg_match($pattern, $path, $m)) {
$args = array_map('intval', array_slice($m, 1));
$result = $handler(...$args);
$status = match ($method) {
'POST' => 201,
'DELETE' => 204, // body will be ignored by most clients
default => 200
};
respond($status, $method === 'DELETE' ? [] : $result);
}
}
respond(404, ['error' => 'Route not found']);
Choosing resourceful URLs and status codes carefully
Use plural nouns for collections like /v1/articles; use identifiers for single resources like /v1/articles/123. Success paths return 200 for retrievals, 201 for creations, 204 for deletions, and 202 for accepted background work. Validation failures use 422; authentication uses 401; authorization uses 403; missing resources use 404.
GET since many caches and crawlers may trigger those URLs. Mutations belong to POST, PUT, PATCH, or DELETE.
Content Negotiation and JSON
APIs should honor client preferences expressed by Accept and declare their own format with Content-Type. JSON is the default interchange format for many PHP services; you should set charset to UTF-8 and avoid surprising field name changes.
Reading Accept and writing Content-Type clearly
You can branch responses based on Accept; if the client asks for application/json send JSON. If the client requests a format you cannot produce return 406.
<?php
$accept = $_SERVER['HTTP_ACCEPT'] ?? 'application/json';
if (strpos($accept, 'application/json') === false) {
http_response_code(406);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'Not acceptable']);
exit;
}
header('Content-Type: application/json; charset=utf-8');
Encoding JSON safely and predictably
Use JSON_UNESCAPED_UNICODE and JSON_UNESCAPED_SLASHES for readable output; add JSON_THROW_ON_ERROR when you want exceptions. Always check json_last_error when decoding input.
<?php
try {
$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
echo $json;
} catch (JsonException $e) {
http_response_code(500);
echo json_encode(['error' => 'Encoding failed']);
}
/v2.
Authentication and Tokens
Authentication identifies the caller; authorization checks what the caller may do. For simple APIs you can use static API keys; for broader integration use bearer tokens like JWT or opaque tokens stored server side. Keep secrets out of URLs since many systems log them.
Parsing Authorization: Bearer … tokens
Expect a header named Authorization with a scheme of Bearer followed by the token. Reject missing or malformed tokens with 401 and a WWW-Authenticate challenge.
<?php
function bearerToken(): string {
$h = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/^Bearer\s+([A-Za-z0-9\-\._~\+\/]+=*)$/', $h, $m)) {
header('WWW-Authenticate: Bearer');
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
return $m[1];
}
// Example verification with an opaque token store
function verifyToken(string $token): ?array {
// Look up in your database or cache. Return user claims or null.
// Example stub:
if ($token === 'test-token-123') return ['sub' => 42, 'scope' => 'articles:read articles:write'];
return null;
}
$token = bearerToken();
$claims = verifyToken($token);
if ($claims === null) {
http_response_code(401);
echo json_encode(['error' => 'Invalid token']);
exit;
}
Choosing between API keys, JWT, and opaque tokens thoughtfully
API keys are simple for server to server calls; rotate keys regularly and scope them. JWT carry claims and can be verified without storage; rotate signing keys and keep lifetimes short. Opaque tokens are random strings that map to server side session data; they are easy to revoke instantly since the server controls validity.
GET query strings since proxies and browsers store history. Use headers or in some cases a request body over HTTPS.
Rate Limiting and Throttling
Rate limiting protects your service and your users by bounding how often clients may call endpoints. You can implement a fixed window counter, a sliding window log, or a token bucket. Choose an approach that fits your storage and your accuracy needs.
Implementing a fixed window counter with shared storage
A fixed window counts hits per key in a short interval like one minute. Use Redis or another fast store; fall back to the filesystem for demos only.
<?php
// Simple filesystem limiter for demos
function limitKey(string $identity, int $max, int $windowSeconds): bool {
$bucket = sys_get_temp_dir() . '/rate_' . sha1($identity);
$now = time();
$window = intdiv($now, $windowSeconds);
$path = $bucket . '_' . $window;
$count = 0;
if (file_exists($path)) {
$count = (int)file_get_contents($path);
}
$count++;
file_put_contents($path, (string)$count, LOCK_EX);
return $count <= $max;
}
// Use IP or user id as the identity
$identity = ($_SERVER['REMOTE_ADDR'] ?? 'anon') . ':articles';
if (!limitKey($identity, 60, 60)) {
header('Retry-After: 60');
http_response_code(429);
echo json_encode(['error' => 'Too many requests']);
exit;
}
Designing response headers for clients
Expose remaining quota and reset times with headers like X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset so clients can back off without guessing.
503 when downstreams fail.
Documentation and OpenAPI
Good documentation reduces support cost and client bugs. OpenAPI describes your endpoints, parameters, and schemas in JSON or YAML so you can generate reference pages and tests. You can serve a static openapi.yaml next to your API and mount a simple viewer.
Authoring a minimal openapi.yaml for clarity
Start with version, info, servers, and a couple of paths. Add request and response schemas as they stabilize.
# openapi.yaml
openapi: 3.0.3
info:
title: Articles API
version: 1.0.0
servers:
- url: https://api.example.com/v1
paths:
/articles:
get:
summary: List articles
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: '#/components/schemas/Article' }
post:
summary: Create article
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/NewArticle' }
responses:
'201': { description: Created }
/articles/{id}:
get:
summary: Get article
parameters:
- in: path
name: id
required: true
schema: { type: integer }
responses:
'200': { description: OK }
components:
schemas:
Article:
type: object
properties:
id: { type: integer }
title: { type: string }
NewArticle:
type: object
required: [title]
properties:
title: { type: string }
Serving interactive docs responsibly
You can host a self contained documentation page that fetches openapi.yaml from your server. Protect internal or preview specs behind authentication; do not leak private endpoints in public docs.
GraphQL Basics with Community Tools
GraphQL exposes a single endpoint that accepts queries describing exactly which fields a client wants. Resolvers fetch data for fields and types. In PHP you can use community packages to parse the query and wire types to resolvers without writing a parser yourself.
Defining a schema and a resolver function
The following example uses a community library to execute a query with a simple resolver. The idea is the same with other libraries; define types and fields, then map each field to a function that returns data.
<?php
// Example with webonyx/graphql-php style APIs (pseudocode for clarity)
require __DIR__ . '/vendor/autoload.php';
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;
use GraphQL\GraphQL;
use GraphQL\Schema\Schema;
$articleType = new ObjectType([
'name' => 'Article',
'fields' => [
'id' => Type::nonNull(Type::int()),
'title' => Type::nonNull(Type::string()),
],
]);
$queryType = new ObjectType([
'name' => 'Query',
'fields' => [
'articles' => [
'type' => Type::listOf($articleType),
'resolve' => function () {
return [['id' => 1, 'title' => 'Hello'], ['id' => 2, 'title' => 'World']];
},
],
],
]);
$schema = new Schema(['query' => $queryType]);
$raw = file_get_contents('php://input') ?: '{}';
$input = json_decode($raw, true);
try {
$result = GraphQL::executeQuery($schema, $input['query'] ?? '{ articles { id title } }');
$output = $result->toArray();
} catch (Throwable $e) {
http_response_code(400);
$output = ['errors' => [['message' => 'Query failed']]];
}
header('Content-Type: application/json; charset=utf-8');
echo json_encode($output, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
Choosing GraphQL appropriately for client needs
GraphQL shines when clients need to combine several resources into one round trip or when different screens require different shapes of data. For uniform feeds and simple filters, classic REST with pagination is usually easier to cache and to secure.