Project: REST API with PHP
What You Will Build
A JSON REST API for the shop's products, usable by a mobile app, a JavaScript front end, or partner systems. Clients can list products (with filtering and pagination), fetch a single product, and — with an API token — create, update, and delete products. The project brings together routing, JSON handling, HTTP methods and status codes, validation, token authentication, and database access.
Endpoints
| Method | Path | Purpose | Authentication |
|---|---|---|---|
GET | /api/products | List products (?category=, ?page=, ?per_page=) | Public |
GET | /api/products/{sku} | Get one product | Public |
POST | /api/products | Create a product | Bearer token |
PATCH | /api/products/{sku} | Change some fields | Bearer token |
DELETE | /api/products/{sku} | Delete a product | Bearer token |
REST in Brief
REST organizes an API around resources (here: products) identified by URLs, and uses HTTP methods to express actions: GET reads, POST creates, PATCH updates partially, PUT replaces, DELETE removes. Status codes report outcomes (200, 201, 204, 400, 401, 404, 409, 422), and JSON carries the data. Clients can rely on these conventions without learning a custom protocol for every API.
Project Structure
shop-api/
├── public/
│ └── index.php ← front controller: every /api request starts here
└── src/
├── bootstrap.php ← pdo(), validateProduct() from the CRUD project
├── Router.php
├── http.php ← json(), fail(), jsonBody(), requireToken()
└── ProductController.php
The web server forwards all requests to public/index.php (see Hiding PHP).
Step 1: A Router
<?php
// src/Router.php
final class Router
{
private array $routes = [];
public function add(string $method, string $pattern, mixed $handler): void
{
$regex = '#^' . preg_replace('#\{(\w+)\}#', '(?P<$1>[^/]+)', $pattern) . '$#';
$this->routes[] = [$method, $regex, $handler];
}
public function match(string $method, string $path): array
{
$allowed = [];
foreach ($this->routes as [$routeMethod, $regex, $handler]) {
if (!preg_match($regex, $path, $matches)) {
continue;
}
if ($routeMethod !== $method) {
$allowed[] = $routeMethod;
continue;
}
$params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
return ['status' => 200, 'handler' => $handler, 'params' => $params, 'allowed' => []];
}
return $allowed === []
? ['status' => 404, 'handler' => null, 'params' => [], 'allowed' => []]
: ['status' => 405, 'handler' => null, 'params' => [], 'allowed' => array_values(array_unique($allowed))];
}
}
Testing the Router
<?php
require __DIR__ . '/src/Router.php';
$router = new Router();
$router->add('GET', '/api/products', 'list');
$router->add('POST', '/api/products', 'create');
$router->add('GET', '/api/products/{sku}', 'show');
$router->add('PATCH', '/api/products/{sku}', 'update');
$router->add('DELETE', '/api/products/{sku}', 'delete');
$tests = [['GET', '/api/products'], ['GET', '/api/products/LAMP-01'], ['PUT', '/api/products/LAMP-01'], ['GET', '/api/orders']];
foreach ($tests as [$method, $path]) {
$match = $router->match($method, $path);
$detail = match ($match['status']) {
200 => $match['handler'] . ($match['params'] ? ' ' . json_encode($match['params']) : ''),
405 => '(Allow: ' . implode(', ', $match['allowed']) . ')',
default => '',
};
printf("%-6s %-24s → %s\n", $method, $path, rtrim($match['status'] . ' ' . $detail));
}
Output
GET /api/products → 200 list
GET /api/products/LAMP-01 → 200 show {"sku":"LAMP-01"}
PUT /api/products/LAMP-01 → 405 (Allow: GET, PATCH, DELETE)
GET /api/orders → 404
How It Works
add()converts a pattern such as/api/products/{sku}into a regular expression with a named group:#^/api/products/(?P<sku>[^/]+)$#.match()finds the route for the path and method. The named groups become parameters (['sku' => 'LAMP-01']);array_filter(..., ARRAY_FILTER_USE_KEY)removes the numeric duplicates thatpreg_match()also returns.- If the path exists but not for this method, the result is 405 Method Not Allowed with the list of allowed methods (sent in the
Allowheader); unknown paths give 404.
Step 2: HTTP Helpers
<?php
// src/http.php
function json(int $status, ?array $body = null, array $headers = []): never
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
foreach ($headers as $name => $value) {
header("$name: $value");
}
if ($body !== null) {
echo json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
exit;
}
function fail(int $status, string $code, string $message, array $fields = [], array $headers = []): never
{
$error = ['code' => $code, 'message' => $message] + ($fields === [] ? [] : ['fields' => $fields]);
json($status, ['error' => $error], $headers);
}
function jsonBody(): array
{
if (!str_starts_with($_SERVER['CONTENT_TYPE'] ?? '', 'application/json')) {
fail(415, 'unsupported_media_type', 'Send the request body as application/json.');
}
try {
$data = json_decode(file_get_contents('php://input', length: 100_000), true, 8, JSON_THROW_ON_ERROR);
} catch (JsonException) {
fail(400, 'invalid_json', 'The request body is not valid JSON.');
}
if (!is_array($data) || array_is_list($data) && $data !== []) {
fail(400, 'invalid_json', 'The request body must be a JSON object.');
}
return $data;
}
function scalarsToStrings(array $values): array
{
return array_map(fn(mixed $value): mixed => is_scalar($value) ? (string) $value : $value, $values);
}
function requireToken(PDO $pdo): void
{
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (preg_match('/^Bearer\s+([a-f0-9]{64})$/i', $header, $m)) {
$stmt = $pdo->prepare('SELECT id FROM api_tokens WHERE token_hash = ?');
$stmt->execute([hash('sha256', strtolower($m[1]))]);
if (($tokenId = $stmt->fetchColumn()) !== false) {
$pdo->prepare('UPDATE api_tokens SET last_used_at = NOW() WHERE id = ?')->execute([$tokenId]);
return;
}
}
fail(401, 'unauthorized', 'A valid API token is required.', headers: ['WWW-Authenticate' => 'Bearer']);
}
These helpers produce the consistent JSON envelope from AJAX with JSON: success responses contain data, errors contain error with a code and message. 415 Unsupported Media Type tells clients they sent the wrong body format. scalarsToStrings() converts JSON numbers (such as "stock": 30) into strings, because the shared validation function expects form-style string input; arrays and objects stay unchanged and fail validation.
API Tokens
CREATE TABLE api_tokens (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
token_hash CHAR(64) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_api_tokens_hash (token_hash)
);
<?php
// bin/create-token.php "Mobile app"
require __DIR__ . '/../src/bootstrap.php';
$token = bin2hex(random_bytes(32));
pdo()->prepare('INSERT INTO api_tokens (name, token_hash) VALUES (?, ?)')
->execute([$argv[1] ?? 'Unnamed client', hash('sha256', $token)]);
echo "Token (shown only once): $token\n";
Only a SHA-256 hash of each token is stored, so a database leak does not expose usable tokens. A fast hash is appropriate here (unlike passwords) because tokens are 256-bit random values that cannot be guessed. Tokens are sent in the Authorization: Bearer ... header — never in URLs, where they would end up in logs.
Step 3: The Controller
<?php
// src/ProductController.php
final class ProductController
{
private const FIELDS = 'sku, name, price, stock, category';
public function __construct(private PDO $pdo) {}
public function list(): never
{
$page = filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]) ?: 1;
$perPage = filter_input(INPUT_GET, 'per_page', FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 50]]) ?: 20;
$category = is_string($_GET['category'] ?? null) ? $_GET['category'] : null;
$where = 'WHERE is_active = 1' . ($category !== null ? ' AND category = :category' : '');
$count = $this->pdo->prepare("SELECT COUNT(*) FROM products $where");
$count->execute($category !== null ? ['category' => $category] : []);
$total = (int) $count->fetchColumn();
$stmt = $this->pdo->prepare('SELECT ' . self::FIELDS . " FROM products $where ORDER BY id LIMIT :limit OFFSET :offset");
if ($category !== null) {
$stmt->bindValue(':category', $category);
}
$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$stmt->bindValue(':offset', ($page - 1) * $perPage, PDO::PARAM_INT);
$stmt->execute();
json(200, [
'data' => $stmt->fetchAll(),
'meta' => ['page' => $page, 'per_page' => $perPage, 'total' => $total, 'total_pages' => (int) ceil($total / $perPage)],
]);
}
public function show(string $sku): never
{
json(200, ['data' => $this->findOrFail($sku)]);
}
public function create(): never
{
requireToken($this->pdo);
['data' => $data, 'errors' => $errors] = validateProduct(scalarsToStrings(jsonBody()) + ['is_active' => true]);
if ($errors !== []) {
fail(422, 'validation_failed', 'The product data is invalid.', $errors);
}
try {
$this->pdo->prepare(
'INSERT INTO products (sku, name, price, stock, category, is_active) VALUES (?, ?, ?, ?, ?, 1)'
)->execute([$data['sku'], $data['name'], $data['price'], $data['stock'], $data['category']]);
} catch (PDOException $e) {
if (($e->errorInfo[1] ?? null) === 1062) {
fail(409, 'duplicate_sku', 'A product with this SKU already exists.');
}
throw $e;
}
json(201, ['data' => $this->findOrFail($data['sku'])], ['Location' => '/api/products/' . rawurlencode($data['sku'])]);
}
public function update(string $sku): never
{
requireToken($this->pdo);
$current = $this->findOrFail($sku);
$changes = array_intersect_key(jsonBody(), array_flip(['name', 'price', 'stock', 'category']));
if ($changes === []) {
fail(422, 'validation_failed', 'Send at least one of: name, price, stock, category.');
}
['data' => $data, 'errors' => $errors] = validateProduct(scalarsToStrings($changes + $current) + ['is_active' => true]);
$errors = array_intersect_key($errors, $changes);
if ($errors !== []) {
fail(422, 'validation_failed', 'The product data is invalid.', $errors);
}
$this->pdo->prepare('UPDATE products SET name = ?, price = ?, stock = ?, category = ? WHERE sku = ?')
->execute([$data['name'], $data['price'], $data['stock'], $data['category'], $sku]);
json(200, ['data' => $this->findOrFail($sku)]);
}
public function delete(string $sku): never
{
requireToken($this->pdo);
$this->findOrFail($sku);
try {
$this->pdo->prepare('DELETE FROM products WHERE sku = ?')->execute([$sku]);
} catch (PDOException $e) {
if (($e->errorInfo[1] ?? null) === 1451) {
fail(409, 'product_in_use', 'The product appears in orders. Set it inactive instead.');
}
throw $e;
}
json(204);
}
private function findOrFail(string $sku): array
{
if (!preg_match('/^[A-Z0-9-]{3,30}$/', $sku)) {
fail(404, 'not_found', 'Product not found.');
}
$stmt = $this->pdo->prepare('SELECT ' . self::FIELDS . ' FROM products WHERE sku = ?');
$stmt->execute([$sku]);
return $stmt->fetch() ?: fail(404, 'not_found', 'Product not found.');
}
}
Key Points
- Pagination parameters are validated with ranges;
per_pageis capped at 50 so clients cannot request the whole table at once. Themetaobject tells clients how many pages exist. - Writes require a token; reads are public. Only active products are listed.
- Validation reuses
validateProduct()from Project: CRUD App with MySQL after converting JSON scalars to strings. ForPATCH, the submitted fields are merged with the current values, and only errors for submitted fields are reported. - Conflicts (duplicate SKU, product used in orders) return 409 Conflict with specific error codes.
201 Createdincludes aLocationheader pointing to the new resource;DELETEreturns204 No Content.findOrFail()uses thenever-returningfail()inside an expression (?: fail(...)), which is valid becausefail()never returns.
Step 4: The Front Controller
<?php
// public/index.php
declare(strict_types=1);
require __DIR__ . '/../src/bootstrap.php';
require __DIR__ . '/../src/Router.php';
require __DIR__ . '/../src/http.php';
require __DIR__ . '/../src/ProductController.php';
set_exception_handler(function (Throwable $e): void {
error_log('API error: ' . $e);
fail(500, 'server_error', 'An unexpected error occurred.');
});
header('Access-Control-Allow-Origin: https://app.gocourse.in');
header('Access-Control-Allow-Headers: Authorization, Content-Type');
header('Access-Control-Allow-Methods: GET, POST, PATCH, DELETE');
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method === 'OPTIONS') {
http_response_code(204); // CORS preflight
exit;
}
$products = new ProductController(pdo());
$router = new Router();
$router->add('GET', '/api/products', $products->list(...));
$router->add('POST', '/api/products', $products->create(...));
$router->add('GET', '/api/products/{sku}', $products->show(...));
$router->add('PATCH', '/api/products/{sku}', $products->update(...));
$router->add('DELETE', '/api/products/{sku}', $products->delete(...));
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$match = $router->match($method, rtrim($path, '/') ?: '/');
match ($match['status']) {
200 => ($match['handler'])(...$match['params']),
405 => fail(405, 'method_not_allowed', 'Method not allowed.', headers: ['Allow' => implode(', ', $match['allowed'])]),
default => fail(404, 'not_found', 'Endpoint not found.'),
};
- First-class callables (
$products->list(...)) are registered as handlers (see First-class Callable Syntax); route parameters are passed as named arguments through the spread operator (['sku' => 'LAMP-01']→show(sku: 'LAMP-01')). - A global exception handler turns unexpected errors into a JSON 500 response — never an HTML error page or a stack trace.
- CORS headers allow a specific browser front end to call the API; the preflight
OPTIONSrequest is answered directly.
Step 5: Using the API
curl -s "https://shop.example/api/products?category=electronics"
{"data":[{"sku":"LAMP-01","name":"Desk Lamp","price":"1299.00","stock":6,"category":"electronics"},{"sku":"MOUSE-01","name":"Wireless Mouse","price":"599.00","stock":30,"category":"electronics"}],"meta":{"page":1,"per_page":20,"total":2,"total_pages":1}}
curl -s -X POST https://shop.example/api/products \
-H "Content-Type: application/json" \
-d '{"sku":"NB-A6","name":"Notebook A6","price":"45.50","stock":30,"category":"stationery"}'
{"error":{"code":"unauthorized","message":"A valid API token is required."}}
curl -s -X POST https://shop.example/api/products \
-H "Authorization: Bearer $API_TOKEN" -H "Content-Type: application/json" \
-d '{"sku":"NB-A6","name":"Notebook A6","price":45.5,"stock":30,"category":"stationery"}'
{"data":{"sku":"NB-A6","name":"Notebook A6","price":"45.50","stock":30,"category":"stationery"}}
| Request | Status |
|---|---|
GET /api/products/BAG-02 | 200 with the product |
GET /api/products/NOPE-99 | 404 not_found |
POST without token | 401 unauthorized (with WWW-Authenticate: Bearer) |
POST with "price": "abc" | 422 validation_failed with fields.price |
POST with an existing SKU | 409 duplicate_sku |
PATCH /api/products/NB-A6 with {"stock": 25} | 200 with the updated product |
DELETE /api/products/PEN-01 (ordered before) | 409 product_in_use |
DELETE /api/products/NB-A6 | 204, empty body |
PUT /api/products/NB-A6 | 405 with Allow: GET, PATCH, DELETE |
Production Considerations
| Topic | Recommendation |
|---|---|
| Versioning | Prefix paths with a version (/api/v1/...) so breaking changes can be introduced as v2 |
| Rate limiting | Limit requests per token or IP (for example, with Redis) and return 429 Too Many Requests |
| HTTPS | Mandatory — tokens and data must never travel unencrypted |
| Documentation | Describe the API with OpenAPI so clients can generate code and test interactively |
| Testing | Automated tests for every endpoint, status code, and validation rule |
| Frameworks | Larger APIs benefit from Laravel, Symfony, or Slim, which provide routing, validation, and serialization |
Common Mistakes
Returning 200 for Everything
Effect: Clients cannot detect errors reliably. Fix: Use precise status codes and a consistent error format.
Exposing Database Rows Directly
Effect: Internal columns leak and the API changes whenever the table changes. Fix: Select or map an explicit set of fields.
Tokens in Query Strings or Stored in Plain Text
Effect: Tokens leak through logs or database dumps. Fix: Use the Authorization header and store only token hashes.
Unlimited Result Sizes
Effect: Slow responses and abuse. Fix: Paginate with a maximum page size.
HTML Error Pages From the API
Effect: Clients fail to parse responses; internal details leak. Fix: Convert all errors, including unexpected ones, to JSON.
Best Practices
- Model the API around resources, HTTP methods, and meaningful status codes.
- Route requests through a single front controller with clear handler mapping.
- Validate every input, paginate every list, and return a consistent JSON envelope.
- Authenticate write operations with hashed bearer tokens over HTTPS, and configure CORS deliberately.
- Version, document, rate-limit, and test the API before others depend on it. Official reference: https://www.php.net/manual/en/book.json.php