PHP REST API

Build a small but production-shaped JSON REST API: routing, validation, JWT auth, pagination, CORS, rate limiting. Pure PHP - no framework required.

Advanced 12 min read 13 examples

REST Principles

VerbPathDoes
GET/usersList users
GET/users/42Read one user
POST/usersCreate user
PUT/users/42Full replace
PATCH/users/42Partial update
DELETE/users/42Remove user

Project Structure

Text
api/
├── public/
│   └── index.php          ← front controller
├── src/
│   ├── Router.php
│   ├── Controllers/
│   └── Repositories/
├── .htaccess              ← route all to public/index.php
└── composer.json
Apache.htaccess
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ public/index.php [QSA,L]

A Tiny Router

PHPsrc/Router.php
<?php
final class Router {
    /** @var array<string, array<string, array{0:callable, 1:string}>> */
    private array $routes = [];

    public function add(string $method, string $pattern, callable $handler): void {
        // Convert /users/{id} -> regex with named groups
        $regex = preg_replace_callback("#\{(\w+)\}#", fn($m) => "(?<{$m[1]}>[^/]+)", $pattern);
        $this->routes[$method]["#^$regex/?$#"] = $handler;
    }

    public function dispatch(string $method, string $path): mixed {
        foreach ($this->routes[$method] ?? [] as $regex => $handler) {
            if (preg_match($regex, $path, $params)) {
                $args = array_filter($params, "is_string", ARRAY_FILTER_USE_KEY);
                return $handler($args);
            }
        }
        http_response_code(404);
        return ["error" => "Not found"];
    }
}

Request & Response Helpers

PHP
<?php
function json_body(): array {
    $raw = file_get_contents("php://input");
    try {
        return $raw === "" ? [] : json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
    } catch (JsonException) {
        http_response_code(400);
        echo json_encode(["error" => "Invalid JSON body"]);
        exit;
    }
}

function respond(mixed $body, int $status = 200): void {
    http_response_code($status);
    header("Content-Type: application/json; charset=utf-8");
    echo json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    exit;
}

CRUD Endpoints

PHPpublic/index.php
<?php
declare(strict_types=1);
require __DIR__ . "/../vendor/autoload.php";

$pdo = new PDO("mysql:host=localhost;dbname=api;charset=utf8mb4", "u", "p", [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES   => false,
]);

$router = new Router();

$router->add("GET", "/users", function () use ($pdo) {
    respond($pdo->query("SELECT id, name, email FROM users")->fetchAll());
});

$router->add("GET", "/users/{id}", function (array $p) use ($pdo) {
    $stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
    $stmt->execute([$p["id"]]);
    $user = $stmt->fetch();
    $user ? respond($user) : respond(["error" => "Not found"], 404);
});

$router->add("POST", "/users", function () use ($pdo) {
    $body = json_body();
    // ... validation ...
    $stmt = $pdo->prepare("INSERT INTO users (name, email) VALUES (?, ?)");
    $stmt->execute([$body["name"], $body["email"]]);
    respond(["id" => (int) $pdo->lastInsertId()], 201);
});

$router->add("DELETE", "/users/{id}", function (array $p) use ($pdo) {
    $pdo->prepare("DELETE FROM users WHERE id = ?")->execute([$p["id"]]);
    respond(null, 204);
});

$path   = parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH);
$method = $_SERVER["REQUEST_METHOD"];
$router->dispatch($method, $path);

Validation

PHP
<?php
function validate_user(array $body): array {
    $errors = [];

    if (empty($body["name"]) || mb_strlen($body["name"]) > 100) {
        $errors["name"] = "Name required, max 100 chars";
    }
    if (empty($body["email"]) || !filter_var($body["email"], FILTER_VALIDATE_EMAIL)) {
        $errors["email"] = "Valid email required";
    }
    if (isset($body["age"]) && (!is_int($body["age"]) || $body["age"] < 13)) {
        $errors["age"] = "Age must be integer >= 13";
    }

    return $errors;
}

// In handler
$body = json_body();
if ($errors = validate_user($body)) {
    respond(["errors" => $errors], 422);
}

Consistent Error Format

PHP
<?php
set_exception_handler(function (Throwable $e) {
    error_log($e);
    http_response_code(500);
    header("Content-Type: application/json");
    echo json_encode([
        "error" => "Internal server error",
        // Only include details in development
    ]);
});

CORS Headers

PHP
<?php
header("Access-Control-Allow-Origin: https://app.example.com");
header("Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type, Authorization");
header("Access-Control-Max-Age: 86400");

if ($_SERVER["REQUEST_METHOD"] === "OPTIONS") {
    http_response_code(204);
    exit;
}
Avoid wildcard origins with credentials

Access-Control-Allow-Origin: * with Allow-Credentials: true is forbidden by browsers. List explicit origins, even if you check a whitelist dynamically.

JWT Authentication

PHP
<?php
// composer require firebase/php-jwt
use Firebase\JWT\JWT;
use Firebase\JWT\Key;

function issue_token(int $userId, string $secret): string {
    return JWT::encode([
        "sub" => $userId,
        "iat" => time(),
        "exp" => time() + 3600,
    ], $secret, "HS256");
}

function require_auth(string $secret): array {
    $header = $_SERVER["HTTP_AUTHORIZATION"] ?? "";
    if (!preg_match("/^Bearer\s+(.+)$/", $header, $m)) {
        respond(["error" => "Authentication required"], 401);
    }
    try {
        $claims = JWT::decode($m[1], new Key($secret, "HS256"));
        return (array) $claims;
    } catch (Throwable $e) {
        respond(["error" => "Invalid token"], 401);
    }
}

// In a protected route
$claims = require_auth($_ENV["JWT_SECRET"]);
$userId = (int) $claims["sub"];

Pagination

PHP
<?php
$router->add("GET", "/users", function () use ($pdo) {
    $page    = max(1, (int) ($_GET["page"] ?? 1));
    $perPage = min(100, max(1, (int) ($_GET["per_page"] ?? 20)));
    $offset  = ($page - 1) * $perPage;

    $stmt = $pdo->prepare("SELECT * FROM users LIMIT :lim OFFSET :off");
    $stmt->bindValue(":lim", $perPage, PDO::PARAM_INT);
    $stmt->bindValue(":off", $offset, PDO::PARAM_INT);
    $stmt->execute();

    $total = (int) $pdo->query("SELECT COUNT(*) FROM users")->fetchColumn();

    respond([
        "data"     => $stmt->fetchAll(),
        "page"     => $page,
        "per_page" => $perPage,
        "total"    => $total,
        "pages"    => (int) ceil($total / $perPage),
    ]);
});

Rate Limiting

PHP
<?php
// Simple file-based rate limit (use Redis in production)
function rate_limit(string $key, int $max, int $windowSeconds): void {
    $path = sys_get_temp_dir() . "/rl_" . md5($key);
    $now  = time();
    $data = file_exists($path)
        ? json_decode(file_get_contents($path), true)
        : ["count" => 0, "start" => $now];

    if ($now - $data["start"] > $windowSeconds) {
        $data = ["count" => 0, "start" => $now];
    }
    $data["count"]++;
    file_put_contents($path, json_encode($data), LOCK_EX);

    if ($data["count"] > $max) {
        header("Retry-After: " . ($windowSeconds - ($now - $data["start"])));
        respond(["error" => "Rate limit exceeded"], 429);
    }
}

rate_limit($_SERVER["REMOTE_ADDR"], 100, 60);   // 100 req/min/IP

Next Steps

Frequently Asked Questions

For learning - roll your own once. For production with more than 5 endpoints - use a framework (Slim, Lumen, Symfony, Laravel). They handle routing, middleware, validation, and security gotchas you'll otherwise reinvent badly.

200 (OK), 201 (created), 204 (no content), 400 (bad input), 401 (auth required), 403 (forbidden), 404 (not found), 409 (conflict), 422 (validation failed), 429 (rate limited), 500 (server error). Be consistent.

JWTs for stateless APIs (mobile, SPA, microservices). Sessions for traditional server-rendered apps. JWTs are not magically more secure - if anything they're harder to revoke. Pick based on your architecture, not buzz.

URL path (/v1/users) is most pragmatic - obvious in logs, easy to route. Header-based versioning (Accept: application/vnd.app.v1+json) is purer but harder to test. Don't version until you actually need to break compatibility.

REST is simpler, cacheable, and well-tooled. GraphQL shines when clients need flexibility (multiple frontends, complex selection sets). For a typical CRUD API, REST is usually the right default.