REST Principles
| Verb | Path | Does |
|---|---|---|
GET | /users | List users |
GET | /users/42 | Read one user |
POST | /users | Create user |
PUT | /users/42 | Full replace |
PATCH | /users/42 | Partial update |
DELETE | /users/42 | Remove user |
Project Structure
api/
├── public/
│ └── index.php ← front controller
├── src/
│ ├── Router.php
│ ├── Controllers/
│ └── Repositories/
├── .htaccess ← route all to public/index.php
└── composer.json
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ public/index.php [QSA,L]
A Tiny Router
<?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
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
<?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
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
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
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;
}
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
// 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
$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
// 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