UK Phone Number Regex for JavaScript and Python (Tested)
Strip spaces, turn +44 into 0, then check for 07 plus nine digits. Tested JavaScript and Python code for every UK number type, E.164 storage, display spacing and libphonenumber.
You can build a proper REST API in plain PHP, with no framework and no Composer packages, using five pieces: a front controller (public/index.php) that receives every request, a small router that matches the HTTP method and URL to a handler, a database layer using PDO with prepared statements, a validation step that rejects bad input with a 422 response, and a helper that sends JSON with the right status code (200, 201, 204, 400, 401, 404, 405, 415 or 422). Add a Bearer token check for anything that changes data, and CORS headers if a browser front end will call it. That's about 300 lines of PHP in total, and you can run it locally with php -S 127.0.0.1:8000 public/index.php.
This tutorial builds exactly that, step by step: a tasks API with list, read, create, replace, update and delete endpoints, pagination and filtering, consistent error responses, token authentication and CORS. I wrote it for PHP 8.4 and ran every request in this post against PHP 8.4.26's built-in server on 6 October 2026, using SQLite so there's nothing to install apart from PHP. The responses below are copied from the terminal, including the parts that surprised me. Switching to MySQL later only means changing one line, the PDO connection string.
A REST API is a set of URLs (resources) and HTTP methods (actions) that return data, usually as JSON. Before writing any code, write the list down. For a simple task list, it looks like this:
GET /api/tasks: list tasks, with ?page=, ?per_page= and ?done=true|false. Returns 200.GET /api/tasks/{id}: one task. 200, or 404 if it doesn't exist.POST /api/tasks: create a task from a JSON body. 201 Created with a Location header pointing to the new task.PUT /api/tasks/{id}: replace a task completely. 200.PATCH /api/tasks/{id}: change only the fields you send. 200.DELETE /api/tasks/{id}: delete a task. 204 No Content, with an empty body.Every error uses the same shape, {"error": "…", "details": {…}}, so whoever calls the API only has to handle one format. Reading is public; creating, changing and deleting need a token.
The code is split into four files, so each one has one job:
tasks-api/
├── public/
│ └── index.php front controller: error handler, CORS, auth, routes
├── src/
│ ├── http.php send_json(), json_body(), HttpError, Router
│ ├── validate.php validate_task()
│ └── TaskRepository.php every SQL query
└── data/
└── tasks.sqlite created automatically on the first request
Only public/ should be reachable from the web. Keeping src/ and data/ outside the web root means nobody can download your code or your database file by guessing a URL. On shared hosting (cPanel and similar), that usually means putting src/ and data/ next to public_html, not inside it, and pointing the front controller at them.
Everything the API sends goes through one function, and every error is an exception. That's the core of the whole design, so it's worth getting right first. Here's src/http.php:
<?php
// src/http.php: JSON responses, JSON request bodies, errors and a tiny router
declare(strict_types=1);
/** Throw this anywhere to stop and send a JSON error with the right status code. */
final class HttpError extends RuntimeException
{
public function __construct(
int $status,
string $message,
public readonly array $details = [],
public readonly array $headers = [],
) {
parent::__construct($message, $status);
}
}
function send_json(int $status, mixed $data = null, array $headers = []): never
{
http_response_code($status);
foreach ($headers as $name => $value) {
header("$name: $value");
}
if ($status !== 204) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
| JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), "\n";
}
exit;
}
/** Read the request body as a JSON object (an associative array). */
function json_body(): array
{
$type = strtolower($_SERVER['CONTENT_TYPE'] ?? '');
if (!str_starts_with($type, 'application/json')) {
throw new HttpError(415, 'Send the request body as application/json.');
}
$raw = file_get_contents('php://input');
if (!json_validate($raw)) {
throw new HttpError(400, 'The request body is not valid JSON.', ['json' => json_last_error_msg()]);
}
$data = json_decode($raw, true);
if (!is_array($data) || ($data !== [] && array_is_list($data))) {
throw new HttpError(400, 'The JSON body must be an object, like {"title": "..."}.');
}
return $data;
}
What each part does:
HttpError is an exception that carries an HTTP status code, an optional details array (used for validation errors) and optional extra headers (used for Allow and WWW-Authenticate). Handlers can throw new HttpError(404, …) from anywhere, even several functions deep, and the request stops there with a clean JSON error. It uses PHP 8's constructor property promotion and readonly properties, so the class is only a few lines.send_json() sets the status code, adds any headers, and encodes the data with JSON_THROW_ON_ERROR, so a value that can't be encoded raises an exception instead of silently sending false. JSON_PRETTY_PRINT makes the output readable while you learn; you can remove it in production to save a few bytes. JSON_UNESCAPED_SLASHES and JSON_UNESCAPED_UNICODE stop PHP from turning / into \/ and "£" into \u00a3. Its return type is never, which tells PHP (and your editor) that it always ends the request.json_body() reads the raw request body from PHP's input stream. You can't use $_POST here: PHP only fills $_POST for form submissions, not for JSON, which is the reason for countless "why is $_POST empty?" questions. It checks three things in order: the Content-Type is application/json (otherwise 415), the text is valid JSON (otherwise 400), and the JSON is an object rather than a list or a bare value (otherwise 400).json_validate() was added in PHP 8.3. It checks whether a string is valid JSON without building the array, and it sets json_last_error_msg() so you can say why it failed. See the PHP manual page for json_validate() for the full signature. On PHP 8.2 or older, call json_decode($raw, true, flags: JSON_THROW_ON_ERROR) inside a try block instead.
Here's what those checks look like from the client's side. The first request has a missing brace, the second sends form data, the third sends a JSON list, and the fourth is valid JSON with invalid content, which Step 5 deals with:
$ php client.php errors
## errors
> POST /api/tasks (with token)
> Content-Type: application/json
> {"title": "Missing a closing brace"
< 400 Bad Request
{
"error": "The request body is not valid JSON.",
"details": {
"json": "Syntax error"
}
}
> POST /api/tasks (with token)
> Content-Type: application/x-www-form-urlencoded
> title=Plain+form+data
< 415 Unsupported Media Type
{
"error": "Send the request body as application/json."
}
> POST /api/tasks (with token)
> Content-Type: application/json
> ["a", "list", "not", "an", "object"]
< 400 Bad Request
{
"error": "The JSON body must be an object, like {\"title\": \"...\"}."
}
> POST /api/tasks (with token)
> Content-Type: application/json
> {"title": " ", "due": "2026-02-30", "priority": "high"}
< 422 Unknown Status Code
{
"error": "The task has validation errors.",
"details": {
"priority": "Unknown field.",
"title": "Title must be a non-empty string.",
"due": "Due date must be a real date in YYYY-MM-DD format, or null."
}
}
Every response is JSON with an error message, even for a body that isn't JSON at all. That matters more than it looks: a JavaScript fetch() call that does await response.json() will crash on an HTML error page, but works fine with these. My JavaScript fetch POST JSON example shows the browser side of exactly this exchange.
Notice the status line for the last request: 422 Unknown Status Code. That's PHP's built-in development server, which doesn't know a text label for 422. The number is what clients read (HTTP/2 doesn't send the text at all), and Apache or nginx will print "Unprocessable Entity" or "Unprocessable Content", so you can ignore it.
A router turns "PATCH /api/tasks/1" into "call the update handler with id: 1". Many tutorials use a long switch on the method and explode() on the URL, which works for two endpoints and becomes unreadable at ten. This one is 35 lines and handles path parameters, 404 and 405:
final class Router
{
private array $routes = [];
public function add(string $method, string $pattern, callable $handler): void
{
// "/api/tasks/{id}" becomes "#^/api/tasks/(?P<id>\d+)$#"
$regex = '#^' . preg_replace('#\{(\w+)\}#', '(?P<$1>\d+)', $pattern) . '$#';
$this->routes[] = [$method, $regex, $handler];
}
public function dispatch(string $method, string $path): void
{
$allowed = [];
foreach ($this->routes as [$routeMethod, $regex, $handler]) {
if (!preg_match($regex, $path, $match)) {
continue;
}
if ($routeMethod !== $method) {
$allowed[] = $routeMethod; // right URL, wrong method
continue;
}
$params = array_filter($match, 'is_string', ARRAY_FILTER_USE_KEY);
$handler(...array_map('intval', $params)); // ['id' => '5'] -> id: 5
return;
}
if ($allowed) {
throw new HttpError(405, "$method is not allowed on $path.",
headers: ['Allow' => implode(', ', array_unique($allowed))]);
}
throw new HttpError(404, "No endpoint matches $path.");
}
}
How it works:
add() turns a pattern such as /api/tasks/{id} into a regular expression, #^/api/tasks/(?P<id>\d+)$#. (?P<id>…) is a named group, so the captured number comes back labelled id. Using \d+ means only digits match, so /api/tasks/abc is a 404 rather than reaching your database code.dispatch() tries each route in turn. If the URL matches and the method matches, it calls the handler, passing the named groups as named arguments: $handler(...['id' => 5]) is the same as $handler(id: 5). That's why the handlers further down can simply declare function (int $id).405 Method Not Allowed with an Allow header listing the methods that would work, or a 404 Not Found if no route matched the URL at all.The difference between 404 and 405 is small but useful: 404 means "this URL doesn't exist", while 405 means "this URL exists, but not for that method", and the Allow header tells the client what to do instead. Here are both, plus a successful delete, a repeated delete and a CORS preflight request (which Step 6 explains):
$ php client.php delete
## delete
> DELETE /api/tasks/3 (with token)
< 204 No Content
(no body)
> DELETE /api/tasks/3 (with token)
< 404 Not Found
{
"error": "Task 3 not found."
}
> DELETE /api/tasks (with token)
< 405 Method Not Allowed
< Allow: GET, POST
{
"error": "DELETE is not allowed on /api/tasks."
}
> OPTIONS /api/tasks/1
< 204 No Content
< Access-Control-Allow-Origin: http://localhost:5173
< Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE
< Access-Control-Allow-Headers: Content-Type, Authorization
< Access-Control-Max-Age: 600
(no body)
> GET /api/nothing-here
< 404 Not Found
{
"error": "No endpoint matches /api/nothing-here."
}
DELETE /api/tasks (with no id) gets 405 and Allow: GET, POST. Deleting task 3 gives 204 No Content with no body; deleting it again gives 404. Some APIs return 204 for the second delete too, on the basis that the end result is the same. Either is defensible; just be consistent and document it.
All SQL lives in one class, TaskRepository. The handlers never see a query, which makes them short, and it means there's exactly one file to check for SQL injection:
<?php
// src/TaskRepository.php: every SQL query lives here, always with placeholders
declare(strict_types=1);
final class TaskRepository
{
public function __construct(private PDO $db)
{
$db->query('CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
due TEXT NULL,
done INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)');
}
public function page(int $limit, int $offset, ?bool $done): array
{
$where = $done === null ? '' : 'WHERE done = :done';
$stmt = $this->db->prepare("SELECT * FROM tasks $where ORDER BY id LIMIT :limit OFFSET :offset");
if ($done !== null) {
$stmt->bindValue(':done', (int) $done, PDO::PARAM_INT);
}
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return array_map($this->shape(...), $stmt->fetchAll());
}
public function count(?bool $done): int
{
if ($done === null) {
return (int) $this->db->query('SELECT COUNT(*) FROM tasks')->fetchColumn();
}
$stmt = $this->db->prepare('SELECT COUNT(*) FROM tasks WHERE done = ?');
$stmt->execute([(int) $done]);
return (int) $stmt->fetchColumn();
}
public function find(int $id): ?array
{
$stmt = $this->db->prepare('SELECT * FROM tasks WHERE id = ?');
$stmt->execute([$id]);
$row = $stmt->fetch();
return $row ? $this->shape($row) : null;
}
public function create(array $task): array
{
$stmt = $this->db->prepare('INSERT INTO tasks (title, due, done, created_at)
VALUES (:title, :due, :done, :created_at)');
$stmt->execute([
'title' => $task['title'],
'due' => $task['due'] ?? null,
'done' => (int) ($task['done'] ?? false),
'created_at' => gmdate('Y-m-d\TH:i:s\Z'),
]);
return $this->find((int) $this->db->lastInsertId());
}
public function update(int $id, array $changes): ?array
{
$current = $this->find($id);
if ($current === null) {
return null;
}
$task = array_merge($current, $changes);
$stmt = $this->db->prepare('UPDATE tasks SET title = :title, due = :due, done = :done WHERE id = :id');
$stmt->execute(['title' => $task['title'], 'due' => $task['due'], 'done' => (int) $task['done'], 'id' => $id]);
return $this->find($id);
}
public function delete(int $id): bool
{
$stmt = $this->db->prepare('DELETE FROM tasks WHERE id = ?');
$stmt->execute([$id]);
return $stmt->rowCount() === 1;
}
/** Database row -> API shape: real integers and booleans, not strings. */
private function shape(array $row): array
{
return [
'id' => (int) $row['id'],
'title' => $row['title'],
'due' => $row['due'],
'done' => (bool) $row['done'],
'created_at' => $row['created_at'],
];
}
}
The points that matter:
?, :title, :limit), never by putting a variable into the SQL string. That's what makes it safe from SQL injection. If prepared statements are new to you, I explain them from scratch in PHP PDO prepared statements for beginners. The one bit of SQL built from a variable, $where, can only ever be one of two fixed strings that I wrote, never user input.LIMIT and OFFSET use bindValue() with PDO::PARAM_INT. If you pass them to execute() as an array, PDO sends them as strings, and MySQL (with emulated prepares, the default) rejects LIMIT '10' with a syntax error. Binding as integers works everywhere.shape() converts each row to the API's format. Databases return many values as strings, and SQLite stores booleans as 0 or 1. Without this step your JSON would contain "id": "1" and "done": 0, and every client would need to convert them. $this->shape(...) is PHP 8.1's first-class callable syntax, a tidy way to pass a method to array_map().update() reads the current row, merges in the changes and writes it back. That one method serves both PUT and PATCH; the difference is what the handler passes in.delete() returns whether a row was actually removed, using rowCount(), so the handler can choose between 204 and 404 without a separate lookup.CREATE TABLE IF NOT EXISTS. That's handy for a tutorial and a small project. On a real project, use a migration script you run once.SQLite is a real database in a single file, and it's built into PHP through the pdo_sqlite extension. It's ideal for learning, prototypes and small sites. To switch to MySQL, change the connection string in index.php to something like mysql:host=localhost;dbname=tasks;charset=utf8mb4, add the username and password, and change AUTOINCREMENT to AUTO_INCREMENT in the table definition. Keep the password in an environment variable or a config file outside the web root, not in the code.
Never trust the body of a request. Anyone can send anything to your API: wrong types, missing fields, extra fields, a 10,000-character title or a date that doesn't exist. Validation turns all of that into one clear 422 response before anything touches the database:
<?php
// src/validate.php: check a task before it goes anywhere near the database
declare(strict_types=1);
/**
* $partial = false for POST and PUT (title required), true for PATCH (send only what changes).
* Returns the clean fields, or throws a 422 listing every problem at once.
*/
function validate_task(array $input, bool $partial): array
{
$errors = [];
$clean = [];
foreach (array_diff(array_keys($input), ['title', 'due', 'done']) as $unknown) {
$errors[$unknown] = 'Unknown field.';
}
if (array_key_exists('title', $input)) {
$title = is_string($input['title']) ? trim($input['title']) : null;
if ($title === null || $title === '') {
$errors['title'] = 'Title must be a non-empty string.';
} elseif (mb_strlen($title) > 120) {
$errors['title'] = 'Title must be 120 characters or fewer.';
} else {
$clean['title'] = $title;
}
} elseif (!$partial) {
$errors['title'] = 'Title is required.';
}
if (array_key_exists('due', $input)) {
$due = $input['due'];
$date = is_string($due) ? DateTimeImmutable::createFromFormat('!Y-m-d', $due) : false;
if ($due === null) {
$clean['due'] = null;
} elseif ($date === false || $date->format('Y-m-d') !== $due) {
$errors['due'] = 'Due date must be a real date in YYYY-MM-DD format, or null.';
} else {
$clean['due'] = $due;
}
}
if (array_key_exists('done', $input)) {
if (!is_bool($input['done'])) {
$errors['done'] = 'Done must be true or false.';
} else {
$clean['done'] = $input['done'];
}
}
if ($partial && $clean === [] && $errors === []) {
$errors['body'] = 'Send at least one of: title, due, done.';
}
if ($errors) {
throw new HttpError(422, 'The task has validation errors.', $errors);
}
return $clean;
}
The rules, and why I chose them:
priority, which this API doesn't support, it's better to say so than to silently ignore it and let the developer think it was saved. It also catches typos like tilte.title must be a string, not empty after trimming, and at most 120 characters. mb_strlen() counts characters rather than bytes, so a title with "é" or "£" isn't counted as longer than it looks.due must be a real date in YYYY-MM-DD format, or null. createFromFormat('!Y-m-d', …) alone isn't enough, because PHP happily "rolls over" impossible dates: 2026-02-30 becomes 2 March. Formatting the result and comparing it with the input catches that. The ! resets the time to midnight, so the comparison isn't affected by the current time.done must be a real JSON boolean. Not "true", not 1. Being strict now saves confusion later.$partial switches between create/replace and update. For POST and PUT the title is required. For PATCH every field is optional, but at least one must be sent.The fourth request in the Step 2 output shows the result: a whitespace-only title, 30 February and an unknown priority field, all reported in one 422 response. The same thinking applies to HTML forms; my guide to PHP form validation and sanitisation covers the form version, including why you validate input and escape output as separate steps.
public/index.php wires everything together. Every request to the API is sent to this file, which sets up error handling, CORS and authentication, registers the routes and dispatches:
<?php
// public/index.php: the front controller. Every request to the API comes through here.
declare(strict_types=1);
require __DIR__ . '/../src/http.php';
require __DIR__ . '/../src/validate.php';
require __DIR__ . '/../src/TaskRepository.php';
// 1. Any uncaught error becomes a JSON response, never an HTML error page
set_exception_handler(function (Throwable $e): void {
if ($e instanceof HttpError) {
$body = ['error' => $e->getMessage()];
if ($e->details) {
$body['details'] = $e->details;
}
send_json($e->getCode(), $body, $e->headers);
}
error_log((string) $e); // full detail goes to the log only
send_json(500, ['error' => 'Something went wrong on our side.']);
});
// 2. CORS: let one front-end origin call the API from the browser
$origin = getenv('ALLOWED_ORIGIN') ?: 'http://localhost:5173';
header("Access-Control-Allow-Origin: $origin");
header('Vary: Origin');
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'OPTIONS') {
send_json(204, headers: [
'Access-Control-Allow-Methods' => 'GET, POST, PUT, PATCH, DELETE',
'Access-Control-Allow-Headers' => 'Content-Type, Authorization',
'Access-Control-Max-Age' => '600',
]);
}
// 3. Anyone can read; changing data needs the Bearer token
function require_token(): void
{
$expected = getenv('API_TOKEN') ?: '';
if ($expected === '') {
throw new RuntimeException('API_TOKEN is not set on the server.');
}
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/^Bearer\s+(\S+)$/', $header, $m) || !hash_equals($expected, $m[1])) {
throw new HttpError(401, 'Missing or invalid API token.', headers: ['WWW-Authenticate' => 'Bearer']);
}
}
if ($method !== 'GET') {
require_token();
}
// 4. Database and routes
$pdo = new PDO('sqlite:' . (getenv('DB_PATH') ?: __DIR__ . '/../data/tasks.sqlite'), options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$tasks = new TaskRepository($pdo);
$router = new Router();
$router->add('GET', '/api/tasks', function () use ($tasks): void {
$page = filter_var($_GET['page'] ?? 1, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
$perPage = filter_var($_GET['per_page'] ?? 10, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 50]]);
$done = isset($_GET['done']) ? filter_var($_GET['done'], FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) : null;
if ($page === false || $perPage === false || (isset($_GET['done']) && $done === null)) {
throw new HttpError(400, 'Use page >= 1, per_page 1 to 50 and done=true or done=false.');
}
$total = $tasks->count($done);
send_json(200, [
'data' => $tasks->page($perPage, ($page - 1) * $perPage, $done),
'meta' => ['page' => $page, 'per_page' => $perPage, 'total' => $total,
'total_pages' => (int) ceil($total / $perPage)],
]);
});
$router->add('GET', '/api/tasks/{id}', function (int $id) use ($tasks): void {
send_json(200, $tasks->find($id) ?? throw new HttpError(404, "Task $id not found."));
});
$router->add('POST', '/api/tasks', function () use ($tasks): void {
$task = $tasks->create(validate_task(json_body(), partial: false));
send_json(201, $task, ['Location' => "/api/tasks/{$task['id']}"]);
});
$router->add('PUT', '/api/tasks/{id}', function (int $id) use ($tasks): void {
// PUT replaces the whole task, so missing optional fields go back to their defaults
$task = validate_task(json_body(), partial: false) + ['due' => null, 'done' => false];
send_json(200, $tasks->update($id, $task) ?? throw new HttpError(404, "Task $id not found."));
});
$router->add('PATCH', '/api/tasks/{id}', function (int $id) use ($tasks): void {
$changes = validate_task(json_body(), partial: true);
send_json(200, $tasks->update($id, $changes) ?? throw new HttpError(404, "Task $id not found."));
});
$router->add('DELETE', '/api/tasks/{id}', function (int $id) use ($tasks): void {
$tasks->delete($id) ? send_json(204) : throw new HttpError(404, "Task $id not found.");
});
$path = rtrim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/') ?: '/';
$router->dispatch($method, $path);
Section by section:
throw new HttpError(…) style work. An HttpError becomes a JSON response with its status code. Anything else, such as a database error or a bug, is written in full to the server's error log, and the client only sees a generic 500 message. Never send exception messages or stack traces to the client: they can reveal file paths, SQL and sometimes credentials.localhost:5173 during development) call the API from the browser. Browsers send an OPTIONS "preflight" request before a PUT, PATCH or DELETE, a POST with a JSON body, or any request with an Authorization header, and the API answers it with 204 and the allowed methods and headers. Set ALLOWED_ORIGIN to your real front-end address in production. Avoid * on an API that uses tokens.require_token() checks the Authorization: Bearer … header on every request that isn't a GET. The expected token comes from an environment variable, so it's never in the code or in Git. hash_equals() compares the two strings in constant time, which stops an attacker from guessing the token one character at a time by measuring how long the comparison takes. If the token is missing or wrong, the client gets 401 with a WWW-Authenticate: Bearer header, which is the standard way to say "this needs a Bearer token".?? throw new HttpError(404, …) uses PHP 8's throw expression to say "or, if that returned null, stop with a 404" in one line.GET /api/tasks validates its query parameters with filter_var(): page must be at least 1, per_page between 1 and 50 (so nobody can request a million rows at once), and done must be true or false. It returns the page of tasks plus a meta object with the totals, so a front end can draw "Page 1 of 2" and next/previous buttons.PUT adds defaults for any optional field that wasn't sent (due becomes null, done becomes false), because PUT means "replace the whole thing". PATCH only changes what it's given.Start the server with a random token in an environment variable. random_bytes(32) gives 256 bits of randomness; bin2hex() turns it into 64 characters you can paste into a header:
#!/usr/bin/env bash
# run.sh: start the API on PHP's built-in server with a fresh database and a random token,
# run every client scenario, then stop the server.
cd "$(dirname "$0")"
rm -rf data && mkdir data
export API_TOKEN="$(php -r 'echo bin2hex(random_bytes(32));')" # random each run, never printed
export DB_PATH="$PWD/data/tasks.sqlite"
php -S 127.0.0.1:8000 public/index.php > server.log 2>&1 &
SERVER=$!
sleep 1
for s in create errors read update delete; do
{ echo "\$ php client.php $s"; php client.php "$s"; } > "$s.out"
done
kill $SERVER
php -v | head -1 > php-version.out
I tested the API with a PHP client that uses PHP's built-in HTTP stream wrapper, so you don't need any extra tools; Postman, Insomnia, Bruno or your browser's fetch() all work just as well. The client prints each request (marked >) and the response status, the interesting headers and the body (marked <). It reads the token from the same environment variable and never prints it:
<?php
// client.php: call the API with PHP's built-in HTTP stream wrapper and print each exchange
declare(strict_types=1);
$base = 'http://127.0.0.1:8000';
$token = getenv('API_TOKEN'); // read from the environment, never hard-coded
function call(string $method, string $path, ?string $body = null, bool $auth = true,
string $type = 'application/json'): void
{
global $base, $token;
$headers = ['Accept: application/json'];
if ($body !== null) {
$headers[] = "Content-Type: $type";
}
if ($auth) {
$headers[] = "Authorization: Bearer $token";
}
$context = stream_context_create(['http' => [
'method' => $method,
'header' => implode("\r\n", $headers),
'content' => $body ?? '',
'ignore_errors' => true, // give us the body of 4xx/5xx responses too
]]);
$response = file_get_contents($base . $path, false, $context);
$received = http_get_last_response_headers();
echo "> $method $path" . ($auth ? ' (with token)' : '') . "\n";
if ($body !== null) {
echo "> Content-Type: $type\n> $body\n";
}
echo '< ' . substr($received[0], 9) . "\n"; // "HTTP/1.1 201 Created" -> "201 Created"
foreach ($received as $line) {
if (preg_match('/^(Location|Allow|WWW-Authenticate):/i', $line)
|| ($method === 'OPTIONS' && preg_match('/^Access-Control-/i', $line))) {
echo "< $line\n";
}
}
echo $response === '' ? "(no body)\n" : $response;
echo "\n";
}
Creating tasks, including one attempt without the token:
$ php client.php create
## create
> GET /api/tasks
< 200 OK
{
"data": [],
"meta": {
"page": 1,
"per_page": 10,
"total": 0,
"total_pages": 0
}
}
> POST /api/tasks
> Content-Type: application/json
> {"title": "Revise for the databases exam"}
< 401 Unauthorized
< WWW-Authenticate: Bearer
{
"error": "Missing or invalid API token."
}
> POST /api/tasks (with token)
> Content-Type: application/json
> {"title": "Revise for the databases exam", "due": "2026-10-20"}
< 201 Created
< Location: /api/tasks/1
{
"id": 1,
"title": "Revise for the databases exam",
"due": "2026-10-20",
"done": false,
"created_at": "2026-10-06T08:31:02Z"
}
> POST /api/tasks (with token)
> Content-Type: application/json
> {"title": "Book a GP appointment"}
< 201 Created
< Location: /api/tasks/2
{
"id": 2,
"title": "Book a GP appointment",
"due": null,
"done": false,
"created_at": "2026-10-06T08:31:02Z"
}
> POST /api/tasks (with token)
> Content-Type: application/json
> {"title": "Submit dissertation proposal", "due": "2026-11-02", "done": false}
< 201 Created
< Location: /api/tasks/3
{
"id": 3,
"title": "Submit dissertation proposal",
"due": "2026-11-02",
"done": false,
"created_at": "2026-10-06T08:31:02Z"
}
The empty list comes back with total_pages: 0. The POST without a token is refused with 401. The three valid POST requests each return 201 Created, a Location header with the new task's URL and the stored task, including its new id and created_at timestamp (in UTC, which is the safest way to store times; convert to UK time when you display it).
Reading, with pagination, a missing task and an out-of-range per_page:
$ php client.php read
## read
> GET /api/tasks?page=1&per_page=2
< 200 OK
{
"data": [
{
"id": 1,
"title": "Revise for the databases exam",
"due": "2026-10-20",
"done": false,
"created_at": "2026-10-06T08:31:02Z"
},
{
"id": 2,
"title": "Book a GP appointment",
"due": null,
"done": false,
"created_at": "2026-10-06T08:31:02Z"
}
],
"meta": {
"page": 1,
"per_page": 2,
"total": 3,
"total_pages": 2
}
}
> GET /api/tasks/2
< 200 OK
{
"id": 2,
"title": "Book a GP appointment",
"due": null,
"done": false,
"created_at": "2026-10-06T08:31:02Z"
}
> GET /api/tasks/99
< 404 Not Found
{
"error": "Task 99 not found."
}
> GET /api/tasks?per_page=500
< 400 Bad Request
{
"error": "Use page >= 1, per_page 1 to 50 and done=true or done=false."
}
Updating with PATCH and PUT, an empty PATCH, and filtering by done:
$ php client.php update
## update
> PATCH /api/tasks/1 (with token)
> Content-Type: application/json
> {"done": true}
< 200 OK
{
"id": 1,
"title": "Revise for the databases exam",
"due": "2026-10-20",
"done": true,
"created_at": "2026-10-06T08:31:02Z"
}
> PUT /api/tasks/2 (with token)
> Content-Type: application/json
> {"title": "Book a GP appointment (call at 8am)"}
< 200 OK
{
"id": 2,
"title": "Book a GP appointment (call at 8am)",
"due": null,
"done": false,
"created_at": "2026-10-06T08:31:02Z"
}
> PATCH /api/tasks/2 (with token)
> Content-Type: application/json
> {}
< 422 Unknown Status Code
{
"error": "The task has validation errors.",
"details": {
"body": "Send at least one of: title, due, done."
}
}
> GET /api/tasks?done=true
< 200 OK
{
"data": [
{
"id": 1,
"title": "Revise for the databases exam",
"due": "2026-10-20",
"done": true,
"created_at": "2026-10-06T08:31:02Z"
}
],
"meta": {
"page": 1,
"per_page": 10,
"total": 1,
"total_pages": 1
}
}
The PATCH changed only done; the title and due date are untouched. The PUT replaced task 2 with just a new title, so due and done went back to their defaults. The empty PATCH was rejected, and ?done=true returned only the finished task.
Finally, what a server error looks like. I started the API with a database path that can't exist:
$ DB_PATH=/nonexistent/folder/tasks.sqlite php -S 127.0.0.1:8000 public/index.php
$ php client.php broken
> GET /api/tasks
< 500 Internal Server Error
{
"error": "Something went wrong on our side."
}
$ grep -c PDOException server-broken.log
1
$ grep -o 'PDOException: [^#]*' server-broken.log | head -1
PDOException: SQLSTATE[HY000] [14] unable to open database file in /workspace/seo-posts-oct6/test/post06/public/index.php:52
The client gets a polite, generic 500. The log gets the real cause, unable to open database file, with the file and line number. That's exactly the split you want.
PHP's built-in server sends every request to index.php because I passed it as the router script. On Apache, you need a rewrite rule in public/.htaccess that sends any request for a file that doesn't exist to index.php, plus one more line so Apache passes the Authorization header through to PHP, which some setups strip:
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [QSA,L]
CGIPassAuth On
I didn't test this .htaccess in this run, because everything above used PHP's built-in server, so check it on your host. If CGIPassAuth isn't allowed there, a common alternative is RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] above the other rule. Set API_TOKEN in your hosting control panel's environment settings, or in a config file outside the web root, never in a file that's committed to Git. I wrote about the same principle for front-end apps in keeping an AI API key off the browser.
$_POST. It's always empty for JSON requests. Read the raw body and decode it, as json_body() does.200 for everything. Clients rely on the status code. Use 201 for created, 204 for an empty success, 400 for a malformed request, 401 for missing or wrong credentials, 404 for not found, 405 for the wrong method, 415 for the wrong content type and 422 for invalid data.display_errors off in production.TaskRepository does.===. Use hash_equals(). And keep tokens in environment variables, not in code.Access-Control-Allow-Origin: * on an authenticated API. Name the origin you trust.per_page limits, one request can try to load your whole table. Cap it."id": "1" and "done": 0 make every client do conversions. Shape the output.createFromFormat() rolls 30 February over to March unless you compare the result with the input.If you're building a front end for this API, the async patterns in my JavaScript async/await tutorial are what you'll use to call it, show a loading state and handle each error status.
Send every request to one front controller (public/index.php), match the method and URL with a small router, read JSON bodies from PHP's input stream with json_decode(), use PDO prepared statements for the database, and reply with json_encode() and the right status code. Keep the code outside the web root and protect write endpoints with a token.
Call http_response_code() with the status, send header('Content-Type: application/json; charset=utf-8'), then echo json_encode($data, JSON_THROW_ON_ERROR) and stop. Wrapping that in one function, like send_json() here, keeps every response consistent, including errors.
PHP only fills $_POST for application/x-www-form-urlencoded and multipart/form-data requests. For application/json you have to read the raw request body yourself with file_get_contents() on PHP's input stream and decode it with json_decode($raw, true), ideally after checking it with json_validate().
The simplest secure option is a Bearer token: the client sends Authorization: Bearer <token>, and the API compares it with a secret from an environment variable using hash_equals(), returning 401 if it doesn't match. For an API with many users, issue a separate token per user and store only a hash of each token in the database.
PUT replaces the whole resource, so any field you leave out goes back to its default. PATCH changes only the fields you send. In this tutorial, PUT /api/tasks/2 with only a title reset due to null, while PATCH /api/tasks/1 with {"done": true} left the title and due date unchanged.
// note
This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.
If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.
Strip spaces, turn +44 into 0, then check for 07 plus nine digits. Tested JavaScript and Python code for every UK number type, E.164 storage, display spacing and libphonenumber.
One-line ellipsis needs white-space: nowrap, overflow: hidden and text-overflow: ellipsis. Tested fixes for flexbox, grid and tables, plus 2- and 3-line truncation with line-clamp.
git branch -d name deletes the local branch, git push origin --delete name deletes the remote one. Tested errors, pruning stale branches, bulk clean-ups and how to undo a delete.