Project: REST API with PHP

JA JASWANT Updated 04 Nov 2026
11 min read ·Lesson 238 of 238

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

MethodPathPurposeAuthentication
GET/api/productsList products (?category=, ?page=, ?per_page=)Public
GET/api/products/{sku}Get one productPublic
POST/api/productsCreate a productBearer token
PATCH/api/products/{sku}Change some fieldsBearer token
DELETE/api/products/{sku}Delete a productBearer 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

  1. add() converts a pattern such as /api/products/{sku} into a regular expression with a named group: #^/api/products/(?P<sku>[^/]+)$#.
  2. 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 that preg_match() also returns.
  3. 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 Allow header); 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

  1. Pagination parameters are validated with ranges; per_page is capped at 50 so clients cannot request the whole table at once. The meta object tells clients how many pages exist.
  2. Writes require a token; reads are public. Only active products are listed.
  3. Validation reuses validateProduct() from Project: CRUD App with MySQL after converting JSON scalars to strings. For PATCH, the submitted fields are merged with the current values, and only errors for submitted fields are reported.
  4. Conflicts (duplicate SKU, product used in orders) return 409 Conflict with specific error codes.
  5. 201 Created includes a Location header pointing to the new resource; DELETE returns 204 No Content.
  6. findOrFail() uses the never-returning fail() inside an expression (?: fail(...)), which is valid because fail() 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 OPTIONS request 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"}}
RequestStatus
GET /api/products/BAG-02200 with the product
GET /api/products/NOPE-99404 not_found
POST without token401 unauthorized (with WWW-Authenticate: Bearer)
POST with "price": "abc"422 validation_failed with fields.price
POST with an existing SKU409 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-A6204, empty body
PUT /api/products/NB-A6405 with Allow: GET, PATCH, DELETE

Production Considerations

TopicRecommendation
VersioningPrefix paths with a version (/api/v1/...) so breaking changes can be introduced as v2
Rate limitingLimit requests per token or IP (for example, with Redis) and return 429 Too Many Requests
HTTPSMandatory — tokens and data must never travel unencrypted
DocumentationDescribe the API with OpenAPI so clients can generate code and test interactively
TestingAutomated tests for every endpoint, status code, and validation rule
FrameworksLarger 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

0 Comments

Reviewed before they appear

No comments yet.

PHP
PHP Home PHP Switch PHP Match Alternative Syntax PHP Loops While Loop Do While Loop For Loop Foreach Loop Nested Loops Break Statement PHP Intro PHP Install PHP Built-in Web Server PHP Syntax PHP Tags PHP and HTML PHP Comments PHP Multiline Comments PHP Variables Variables Scope PHP Casting Logical Operators Null Coalescing Operator Spaceship Operator Operator Precedence Continue Statement Modify Strings PHP If...Else...Elseif PHP If Modify Strings PHP If Operators PHP Functions Concatenate Strings PHP If...Else User-defined Functions Slicing Strings Function Arguments PHP Shorthand if Escape Characters Default Argument Values PHP Nested if Pass by Reference String Interpolation Named Arguments PHP File Upload Variadic Functions Return Values Search in Strings Multibyte Strings Type Declarations in Functions PHP Numbers PHP Math PHP Floats PHP Random Numbers PHP Constants PHP Properties PHP Methods PHP Constructor Constructor Property Promotion PHP Cookies PHP Destructor PHP Access Modifiers Readonly Properties PHP Sessions PHP Inheritance Method Overriding PHP Filters The final Keyword CSRF Protection PHP Filters Advanced Session Security Secure Cookies PHP Magic Constants File Upload Security PHP Callback Functions PHP Operators Filesystem Security PHP JSON Arithmetic Operators Error Reporting in Production Hiding PHP Assignment Operators PHP Configuration (php.ini) Comparison Operators PHP Command Line (CLI) PHP Exceptions Increment / Decrement Operators Migrating to PHP 8 PHP Form Validation PHP Form Required PHP Form URL/E-mail Error Handling Form Input Sanitizing Escaping Output (htmlspecialchars) Custom Error Handler Checkbox, Radio and Select Inputs Sticky Forms PHP Form Complete PHP Date and Time Date Formatting Error Logging HTTP Headers and Redirects Output Buffering Sending Email PHP cURL Consuming REST APIs PHP Generators AJAX PHP AJAX Database AJAX XML AJAX Live Search AJAX Poll AJAX with JSON Security Introduction Password Hashing SQL Injection Prevention XSS Prevention Garbage Collection Project: Simple Blog PHP What is OOP PHP Classes/Objects The $this Keyword Access Array Items Update Array Items Add Array Items Remove Array Items Sorting Arrays Multidimensional Arrays Array Destructuring Array Spread Operator Search in Arrays Merge Arrays PHP Abstract Classes PHP Interfaces Polymorphism PHP Traits PHP Static Methods PHP Static Properties Late Static Binding Magic Methods Object Cloning MySQL Database MySQL Connect MySQL Create DB MySQL Create Table MySQL Insert Data MySQL Get Last ID MySQL Insert Multiple MySQL Prepared MySQL Select Data MySQL Where Debugging PHP Project: Image Upload Gallery $_SESSION $_ENV PHP RegEx RegEx Patterns Heredoc and Nowdoc RegEx Modifiers preg_match preg_replace preg_split PHP RegEx Functions Nullable Types Mixed and Never Types Nullsafe Operator PHP Attributes PHP Fibers Readonly Classes Property Hooks (PHP 8.4) Asymmetric Visibility (PHP 8.4) Reflection API SPL Data Structures Composer PHP Form Handling PDO Transactions PDO Error Handling PHP XML Parsers PHP SimpleXML Parser PHP SimpleXML - Get PHP XML Expat Parser PHP DOM Parser PHP XMLReader PHP XMLWriter AJAX Intro PHP Class Constants Encryption with Sodium Recursive Functions Variable Functions Anonymous Functions Closures Arrow Functions First-class Callable Syntax PHP Arrays Indexed Arrays Associative Arrays Create Arrays Variables Scope Static Variables Variable Variables PHP Echo / Print PHP printf / sprintf PHP Data Types PHP Booleans PHP NULL PHP Strings String Functions array_map / array_filter / array_reduce Array Functions PHP Superglobals $GLOBALS What Is `$_SERVER` ? $_REQUEST $_POST $_GET $_FILES $_COOKIE PHP Type Juggling MySQL Order By MySQL Delete Data MySQL Update Data MySQL Limit Data MySQL Joins with PHP Pagination with PHP PDO Introduction PDO Connect PDO Prepared Statements PDO Fetch Modes PSR-4 Autoloading Installing Extensions (PECL / PIE) OPcache & Performance PHP-FPM Project: Contact Form with Email Project: Login & Registration System Project: CRUD App with MySQL Project: Shopping Cart with Sessions Project: REST API with PHP
Ask about this post
AI Ask about this post

Ask questions about Project: REST API with PHP and get answers drawn from it.

Signed-in readers only.