<< Home

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.

💡 Start with simple and explicit code paths; only factor to classes or frameworks when duplication or complexity appears.

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.

⚠️ Do not tunnel actions through 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']);
}
💡 Keep field names stable; when you must rename, keep both the old and new names for a deprecation window, then remove the old field in a new version like /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.

⚠️ Never put secrets in 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.

💡 Separate limits by route groups; reads may need a higher budget than writes. Keep an emergency global circuit breaker that returns 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.

⚠️ Keep examples realistic and sanitized. Never embed production secrets or internal hostnames inside shared documentation.

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.

💡 Whether you choose REST or GraphQL, write contract tests that hit real endpoints and assert status, headers, and a few key fields. Contract tests protect your clients from accidental breaking changes during refactors.