Building a REST API in PHP

Build a small JSON REST API in plain PHP: routes for each HTTP method, reading JSON request bodies, validation, status codes, error responses and testing with curl.

Lesson 25 of 86Advanced17 min read

Websites aren't the only thing PHP serves. Mobile apps, JavaScript front ends built with React or Vue, other servers and command-line tools all need data too, but they don't want HTML pages. They want data, usually as JSON, through an API: a set of addresses designed for programs instead of people.

The most common style is REST. Each kind of thing, such as products or orders, is a resource with its own address, and the HTTP method says what to do with it: GET to read, POST to create, PUT or PATCH to update, DELETE to remove. Responses use status codes like 200, 201 and 404 so the client knows exactly what happened.

In this lesson you'll build a small products API in plain PHP: routing, JSON in and out, validation, status codes, an API key check and curl tests.

Resources, methods and status codes

Method and pathMeaningSuccess status
GET /api/productsList products200 OK
GET /api/products/3One product200 OK
POST /api/productsCreate a product201 Created
PUT /api/products/3Replace product 3200 OK
DELETE /api/products/3Delete product 3204 No Content

Common error statuses: 400 (the request is malformed, such as invalid JSON), 401 (no valid credentials), 404 (no such resource), 405 (method not allowed here), 422 (the data failed validation) and 500 (the server had an error). Using the right code lets clients handle problems without reading your messages.

JSON in and out

To send JSON, set the header and encode the data with json_encode:

PHP
http_response_code(201);
header('Content-Type: application/json');
echo json_encode($product);

To read JSON that a client sends, read the raw request body. $_POST only works for HTML forms:

PHP
$data = json_decode(file_get_contents('php://input'), true);

If json_decode returns null for a non-empty body, the JSON was invalid: respond with 400. Then validate the fields just as you would a form, and respond with 422 and a list of problems if any fail.

One entry point and a router

Send every API request to one file, api/index.php, using a rewrite rule on the web server (or php -S localhost:8000 api/index.php while developing). That file reads the method and path, then decides which code to run:

PHP
$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

A match on the method combined with a regular expression on the path, such as #^/api/products/(\d+)$#, is enough for a small API. Frameworks like Slim and Laravel provide routers that do this for you with nicer syntax.

Authentication and CORS

APIs don't use login forms. Clients send a credential with each request, most simply an API key in a header: Authorization: Bearer abc123.... Check it on every request and respond with 401 if it's missing or wrong. Store keys as hashes, like passwords, and allow each one only what it needs.

If a JavaScript front end on another domain calls your API from the browser, the browser enforces CORS: your API must send headers such as Access-Control-Allow-Origin: https://app.example.com to allow it. Only list origins you trust.

PHP REST API Syntax

API building blocks

$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$body = json_decode(file_get_contents('php://input'), true);
http_response_code(201);
header('Content-Type: application/json');
echo json_encode($data, JSON_PRETTY_PRINT);
preg_match('#^/api/products/(\d+)$#', $path, $m);
$key = $_SERVER['HTTP_AUTHORIZATION'] ?? '';

PHP REST API Summary

  • REST APIs use resources (like /api/products/3) and HTTP methods: GET, POST, PUT/PATCH, DELETE.
  • Return JSON with Content-Type: application/json and an accurate status code.
  • Read JSON request bodies from php://input with json_decode; $_POST is only for forms.
  • Validate input and respond with 422 and clear messages; use 404, 401 and 400 for other problems.
  • Route all requests through one entry file, and check an API key on every request.

PHP REST API Examples

Each example shows the code, the output it produces and what each part does.

Example 1

A small products API

PHP
<?php
// api/index.php
require __DIR__ . '/../db.php';
header('Content-Type: application/json');

function respond(int $status, $data = null): never {
    http_response_code($status);
    if ($data !== null) echo json_encode($data);
    exit;
}

$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

if ($path === '/api/products' && $method === 'GET') {
    $rows = $pdo->query('SELECT id, name, price FROM products ORDER BY id')->fetchAll();
    respond(200, $rows);
}

if (preg_match('#^/api/products/(\d+)$#', $path, $m)) {
    $id = (int)$m[1];
    $stmt = $pdo->prepare('SELECT id, name, price FROM products WHERE id = ?');
    $stmt->execute([$id]);
    $product = $stmt->fetch() ?: respond(404, ['error' => 'Product not found']);

    if ($method === 'GET') respond(200, $product);
    if ($method === 'DELETE') {
        $pdo->prepare('DELETE FROM products WHERE id = ?')->execute([$id]);
        respond(204);
    }
    respond(405, ['error' => 'Method not allowed']);
}

respond(404, ['error' => 'No such endpoint']);
Output
GET /api/products     -> 200 [{"id":1,"name":"Notebook","price":"3.50"},...]
GET /api/products/3   -> 200 {"id":3,"name":"Backpack","price":"34.99"}
GET /api/products/99  -> 404 {"error":"Product not found"}
DELETE /api/products/3 -> 204 (empty body)
GET /api/orders       -> 404 {"error":"No such endpoint"}
Line-by-line explanation
  1. Line 4Every response from this file is JSON.
  2. Lines 6 to 10One helper sets the status, sends the JSON and stops. The never return type means it always exits.
  3. Lines 15 to 18The collection route: list every product.
  4. Lines 20 to 21A regex captures the id from paths like /api/products/3.
  5. Line 24If no row is found, respond with 404 straight away.
  6. Lines 27 to 30DELETE returns 204 No Content, with no body.
Example 2

Creating with validation

PHP
<?php
// inside api/index.php, before the final 404
if ($path === '/api/products' && $method === 'POST') {
    $data = json_decode(file_get_contents('php://input'), true);
    if (!is_array($data)) {
        respond(400, ['error' => 'Request body must be valid JSON']);
    }

    $errors = [];
    $name = trim($data['name'] ?? '');
    $price = $data['price'] ?? null;
    if ($name === '') $errors['name'] = 'Name is required';
    if (!is_numeric($price) || $price <= 0) $errors['price'] = 'Price must be a positive number';
    if ($errors) respond(422, ['errors' => $errors]);

    $pdo->prepare('INSERT INTO products (name, price) VALUES (?, ?)')->execute([$name, $price]);
    $id = (int)$pdo->lastInsertId();
    header("Location: /api/products/$id");
    respond(201, ['id' => $id, 'name' => $name, 'price' => (float)$price]);
}
Output
{"name":"Lamp","price":24.5}  -> 201 {"id":7,"name":"Lamp","price":24.5}
{"name":"","price":-1}        -> 422 {"errors":{"name":"Name is required","price":"Price must be a positive number"}}
not json at all               -> 400 {"error":"Request body must be valid JSON"}
Line-by-line explanation
  1. Line 4Read the raw JSON body; true gives associative arrays.
  2. Lines 5 to 7Anything that isn't a JSON object is a 400 Bad Request.
  3. Lines 12 to 14Collect every validation problem and return them together with 422.
  4. Lines 18 to 19201 Created, with a Location header pointing at the new resource.
Example 3

Testing with curl

Terminal
curl http://localhost:8000/api/products/1
curl -X POST http://localhost:8000/api/products \
     -H "Content-Type: application/json" \
     -d '{"name":"Water bottle","price":12}'
curl -i -X DELETE http://localhost:8000/api/products/7
Output
{"id":1,"name":"Notebook","price":"3.50"}
{"id":8,"name":"Water bottle","price":12}
HTTP/1.1 204 No Content
Content-Type: application/json
Line-by-line explanation
  1. Line 1A GET request is curl's default.
  2. Lines 2 to 4-X sets the method, -H adds a header and -d sends the body. The backslashes continue the command on the next line.
  3. Line 5-i shows the response's status line and headers, useful for checking status codes.

Common PHP REST API Mistakes

Reading JSON from $_POST

Don't
$name = $_POST['name'];       // empty: $_POST only holds form data
Do
$data = json_decode(file_get_contents('php://input'), true);
$name = $data['name'] ?? '';

Always returning 200

Don't
echo json_encode(['error' => 'Not found']);     // status is still 200 OK
Do
http_response_code(404);
echo json_encode(['error' => 'Not found']);

Leaking database errors to clients

Don't
catch (PDOException $e) { echo json_encode(['error' => $e->getMessage()]); }
Do
catch (PDOException $e) {
    error_log($e->getMessage());
    respond(500, ['error' => 'Something went wrong']);
}

PHP REST API in Real Projects

APIs in practice

Mobile and single-page apps talk to PHP back ends through JSON APIs. Laravel and Symfony add routing, tokens and rate limiting, but the core ideas are the ones you built here.

PHP REST API FAQ

What is the difference between PUT and PATCH?

PUT replaces the whole resource with the representation sent by the client, so missing fields are treated as removed or reset. PATCH changes only the fields included in the request. Many APIs support PATCH for edits because clients rarely want to resend every field.

How do I paginate API results?

Accept page and limit, or a cursor, as query parameters, enforce a maximum limit, and return the items with information about the next page, such as {"data": [...], "meta": {"page": 2, "total": 140}} or a next link. Cursor-based pagination stays reliable when items are added while a client is paging.

How should I version an API?

The simplest approach is a version in the path, such as /api/v1/products. Add fields freely, since clients should ignore fields they don't know, but introduce a new version for breaking changes such as renamed or removed fields. Keep the old version running until clients have moved.

How do I document a PHP API?

Describe it with an OpenAPI specification, a YAML or JSON file listing endpoints, parameters, request bodies and responses. Tools such as Swagger UI turn it into interactive documentation, and the same file can generate client code and tests.

Should I build an API in plain PHP or use a framework?

Plain PHP is ideal for learning how routing, status codes and JSON work. For production APIs, frameworks such as Laravel and Symfony, or micro-frameworks such as Slim, add routing, validation, authentication and error handling that are tested by thousands of projects.

PHP REST API Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Fill in the blankseasy

Complete the line that tells the client the response is JSON.

header('Content-Type: application/');
Multiple choiceeasy

Which HTTP method is used to create a new resource in a REST API?

Multiple choicemedium

Which status code means the data failed validation?

Multiple choicemedium

How do you read a JSON request body in PHP?

Multiple choicehard

What should a successful DELETE usually return?

PHP REST API Quiz

5 questions to check what you learned.

  1. What does REST use to say what action to perform?

    • The file name
    • A query string called action
    • Cookies
    • The HTTP method, such as GET, POST or DELETE
  2. What does status code 201 mean?

    • Not found
    • Unauthorized
    • Created
    • No content
  3. Why send JSON even for errors?

    • It's required by HTTP
    • It makes responses smaller
    • Clients can handle every response the same way and show clear messages
    • Browsers need it
  4. What is CORS about?

    • Compressing responses
    • Allowing JavaScript on other domains to call your API from the browser
    • Caching JSON
    • Encrypting passwords
  5. Which status means the request didn't include valid credentials?

    • 401
    • 403
    • 400
    • 405