05. REST API

This module covers how to build a RESTful API in PHP running under Apache. We'll implement full CRUD operations using a JSON file for storage.

Demo Files

Try it live:

curl https://cse135.site/php-tutorial/05-rest-api/api.php/items

The PHP Approach to REST

Building a REST API in PHP is fundamentally different from Node.js because of PHP's per-request execution model:

Node.js REST API: PHP REST API: Browser ─▶ Express (Node.js) Browser ─▶ Apache ─▶ PHP │ │ │ Process runs .htaccess rewrites continuously URL to api.php │ │ In-memory data Read REQUEST_METHOD persists between + PATH_INFO requests │ │ Read/write items.json res.json(data) (file-based storage) │ echo json_encode($data)
Key difference: Node.js can store data in variables because the process stays running. PHP starts fresh on every request, so we need external storage. Here we use a JSON file; in production you'd use a database.

URL Rewriting with .htaccess

RESTful URLs like /api/items/42 don't map to PHP files by default. We use Apache's mod_rewrite to route clean URLs to our API script:

RewriteEngine On
RewriteRule ^api/items(/.*)?$ api.php$1 [L,QSA]

This sends any request to api/items or api/items/anything to api.php, preserving the path information.

Alternative approach: You can also access the API directly via api.php/items and api.php/items/42. Apache passes the part after .php as PATH_INFO automatically. The .htaccess rewrite just makes the URLs cleaner.

Routing in PHP

Without a framework, PHP routing means reading two things from the request:

  1. $_SERVER['REQUEST_METHOD'] — the HTTP verb (GET, POST, PUT, DELETE)
  2. $_SERVER['PATH_INFO'] — the URL path after the script name
<?php
// Reading the HTTP method
$method = $_SERVER['REQUEST_METHOD'];

// Reading the path: /items or /items/42
$path = $_SERVER['PATH_INFO'] ?? '/';

// Parse out the resource ID if present
$parts = explode('/', trim($path, '/'));
// $parts[0] = 'items'
// $parts[1] = '42' (or not set for collection requests)
$id = $parts[1] ?? null;
?>

Reading the JSON Request Body

For POST and PUT requests, the client sends JSON in the request body. PHP doesn't auto-parse this like it does for form data:

<?php
// For form data, PHP gives you $_POST
// For JSON, you read the raw body:
$rawBody = file_get_contents('php://input');
$data = json_decode($rawBody, true);  // true = associative array

// Now $data['name'] contains the value from {"name": "New Item"}
?>
Why php://input? PHP's $_POST only works for application/x-www-form-urlencoded and multipart/form-data. For JSON bodies (application/json), you must read the raw input stream.

Complete CRUD Implementation

Here's the full API implementation. The complete file is at api.php.

Setup and Routing

<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');

// Handle CORS preflight
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    http_response_code(200);
    exit;
}

// Parse request
$method = $_SERVER['REQUEST_METHOD'];
$path = $_SERVER['PATH_INFO'] ?? '/';
$parts = explode('/', trim($path, '/'));
$resource = $parts[0] ?? '';
$id = isset($parts[1]) ? (int)$parts[1] : null;

// Only handle /items routes
if ($resource !== 'items') {
    http_response_code(404);
    echo json_encode(['error' => 'Not found']);
    exit;
}

// Load data from JSON file
$dataFile = __DIR__ . '/items.json';
$items = json_decode(file_get_contents($dataFile), true) ?: [];
?>

GET - Read Items

<?php
if ($method === 'GET') {
    if ($id !== null) {
        // GET /items/:id - Find one item
        $item = null;
        foreach ($items as $i) {
            if ($i['id'] === $id) {
                $item = $i;
                break;
            }
        }
        if (!$item) {
            http_response_code(404);
            echo json_encode(['error' => 'Item not found']);
            exit;
        }
        echo json_encode($item);
    } else {
        // GET /items - Return all items
        echo json_encode($items);
    }
}
?>

POST - Create Item

<?php
if ($method === 'POST') {
    $data = json_decode(file_get_contents('php://input'), true);

    if (!$data || empty($data['name'])) {
        http_response_code(400);
        echo json_encode(['error' => 'Name is required']);
        exit;
    }

    // Generate next ID
    $maxId = 0;
    foreach ($items as $i) {
        if ($i['id'] > $maxId) $maxId = $i['id'];
    }

    $newItem = [
        'id'        => $maxId + 1,
        'name'      => $data['name'],
        'completed' => false
    ];

    $items[] = $newItem;
    file_put_contents($dataFile, json_encode($items, JSON_PRETTY_PRINT));

    http_response_code(201);
    echo json_encode($newItem);
}
?>

PUT - Update Item

<?php
if ($method === 'PUT') {
    if ($id === null) {
        http_response_code(400);
        echo json_encode(['error' => 'Item ID required']);
        exit;
    }

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

    foreach ($items as &$item) {
        if ($item['id'] === $id) {
            if (isset($data['name']))      $item['name'] = $data['name'];
            if (isset($data['completed'])) $item['completed'] = (bool)$data['completed'];
            $found = true;
            $updated = $item;
            break;
        }
    }
    unset($item); // break reference

    if (!$found) {
        http_response_code(404);
        echo json_encode(['error' => 'Item not found']);
        exit;
    }

    file_put_contents($dataFile, json_encode($items, JSON_PRETTY_PRINT));
    echo json_encode($updated);
}
?>

DELETE - Remove Item

<?php
if ($method === 'DELETE') {
    if ($id === null) {
        http_response_code(400);
        echo json_encode(['error' => 'Item ID required']);
        exit;
    }

    $found = false;
    $deleted = null;
    foreach ($items as $index => $item) {
        if ($item['id'] === $id) {
            $deleted = $item;
            array_splice($items, $index, 1);
            $found = true;
            break;
        }
    }

    if (!$found) {
        http_response_code(404);
        echo json_encode(['error' => 'Item not found']);
        exit;
    }

    file_put_contents($dataFile, json_encode($items, JSON_PRETTY_PRINT));
    echo json_encode(['message' => 'Item deleted', 'item' => $deleted]);
}
?>

Status Codes in PHP

PHP uses http_response_code() to set status codes:

<?php
http_response_code(200);  // OK (default)
http_response_code(201);  // Created (after successful POST)
http_response_code(400);  // Bad Request (invalid input)
http_response_code(404);  // Not Found (item doesn't exist)
http_response_code(405);  // Method Not Allowed
http_response_code(500);  // Internal Server Error
?>
Remember: http_response_code() must be called before any output, just like header(). PHP sends headers with the first byte of output.

CORS in PHP

Cross-origin requests require specific headers. In PHP, set them with header():

<?php
// Allow requests from any origin
header('Access-Control-Allow-Origin: *');

// Allow specific methods
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');

// Allow specific headers in requests
header('Access-Control-Allow-Headers: Content-Type');

// Handle the OPTIONS preflight request
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    http_response_code(200);
    exit;  // Don't process further - just return the headers
}
?>

The browser sends an OPTIONS preflight request before any non-simple request (PUT, DELETE, or requests with custom headers). Your API must respond to it.

Testing with curl

# GET all items
curl https://cse135.site/php-tutorial/05-rest-api/api.php/items

# GET one item
curl https://cse135.site/php-tutorial/05-rest-api/api.php/items/1

# POST - Create item
curl -X POST https://cse135.site/php-tutorial/05-rest-api/api.php/items \
  -H "Content-Type: application/json" \
  -d '{"name": "Learn REST"}'

# PUT - Update item
curl -X PUT https://cse135.site/php-tutorial/05-rest-api/api.php/items/1 \
  -H "Content-Type: application/json" \
  -d '{"name": "Updated Item", "completed": true}'

# DELETE - Remove item
curl -X DELETE https://cse135.site/php-tutorial/05-rest-api/api.php/items/1

Comparison with Node.js

Aspect PHP (this tutorial) Node.js (Express)
Routing Manual (PATH_INFO + switch) Built-in (app.get(), app.post())
JSON body file_get_contents('php://input') req.body (with middleware)
Storage JSON file (must persist externally) In-memory array (process persists)
Status codes http_response_code(201) res.status(201)
Response echo json_encode($data) res.json(data)
URL params Parse $_SERVER['PATH_INFO'] req.params.id
Deployment Drop files in Apache docroot Run node server.js + process manager
Which is "better"? Neither. PHP's approach is simpler to deploy (just upload files), while Node.js has nicer developer ergonomics (built-in routing, middleware). In production, both are viable. The concepts (CRUD, status codes, JSON, CORS) are identical.