AJAX with JSON
JSON as the Language of AJAX In simple terms: JSON is the standard way for JavaScript and PHP to exchange data. The browser sends JSON describing what it wants ("create a task called Buy notebooks "),
JSON as the Language of AJAX
In simple terms: JSON is the standard way for JavaScript and PHP to exchange data. The browser sends JSON describing what it wants ("create a task called Buy notebooks"), and PHP answers with JSON describing the result. For this conversation to work smoothly, both sides should follow consistent rules: the same structure for successful responses, the same structure for errors, and meaningful HTTP status codes.
Technically: A JSON API endpoint reads JSON request bodies from php://input, validates them, performs the action, and responds with Content-Type: application/json, an appropriate status code, and a predictable response envelope. On the client, a small wrapper around fetch() sends JSON, parses responses, and turns error responses into exceptions with useful information. This lesson builds a small task list API with create, read, update, and delete operations.
Designing Consistent Responses
| Situation | Status | Body |
|---|---|---|
| Data returned | 200 OK | {"data": ...} |
| Resource created | 201 Created | {"data": {...new resource...}} |
| Deleted, nothing to return | 204 No Content | (empty) |
| Malformed JSON | 400 Bad Request | {"error": {"code": "invalid_json", "message": "..."}} |
| Validation failed | 422 Unprocessable Content | {"error": {"code": "validation_failed", "message": "...", "fields": {...}}} |
| Not found | 404 Not Found | {"error": {"code": "not_found", "message": "..."}} |
| Wrong HTTP method | 405 Method Not Allowed | {"error": {"code": "method_not_allowed", ...}} |
Two rules make client code simple:
- Success responses always contain
data; error responses always containerrorwith a machine-readablecodeand a human-readablemessage. - The status code always matches the outcome — the client never needs to inspect the body to know whether a request succeeded.
Validating JSON Input
<?php
// src/task-validation.php
function validateTaskInput(mixed $input): array
{
if (!is_array($input) || array_is_list($input) && $input !== []) {
return [null, ['_body' => 'Request body must be a JSON object.']];
}
$title = $input['title'] ?? '';
$title = is_string($title) ? trim(preg_replace('/\s+/u', ' ', $title)) : '';
if ($title === '') {
return [null, ['title' => 'Title is required.']];
}
if (mb_strlen($title) > 120) {
return [null, ['title' => 'Title must be at most 120 characters.']];
}
return [['title' => $title], []];
}
$cases = [
['title' => ' Buy notebooks '],
['title' => ''],
['title' => str_repeat('x', 121)],
'not an object',
];
foreach ($cases as $case) {
[$data, $errors] = validateTaskInput($case);
echo json_encode($errors === [] ? ['data' => $data] : ['errors' => $errors]) . "\n";
}
Output
{"data":{"title":"Buy notebooks"}}
{"errors":{"title":"Title is required."}}
{"errors":{"title":"Title must be at most 120 characters."}}
{"errors":{"_body":"Request body must be a JSON object."}}
- A decoded JSON body can be anything: an object, a list, a string, a number. The function first checks that it received an object (an associative array).
- Whitespace is normalized, and the title is validated for presence and length.
- The function returns data and errors separately, so it can be tested without HTTP.
The Endpoint: api/tasks.php
The tasks are kept in the session to keep the example self-contained; a real application would use a database table with the same API.
<?php
declare(strict_types=1);
require __DIR__ . '/../src/task-validation.php';
session_start();
$_SESSION['tasks'] ??= [];
$_SESSION['next_task_id'] ??= 1;
function respond(int $status, ?array $body = null): never
{
http_response_code($status);
if ($body !== null) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
}
exit;
}
function fail(int $status, string $code, string $message, array $fields = []): never
{
$error = ['code' => $code, 'message' => $message];
if ($fields !== []) {
$error['fields'] = $fields;
}
respond($status, ['error' => $error]);
}
function jsonBody(): mixed
{
try {
return json_decode(file_get_contents('php://input', length: 10_000), true, 8, JSON_THROW_ON_ERROR);
} catch (JsonException) {
fail(400, 'invalid_json', 'The request body is not valid JSON.');
}
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
if ($method !== 'GET') {
$sent = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (empty($_SESSION['csrf_token']) || !is_string($sent) || !hash_equals($_SESSION['csrf_token'], $sent)) {
fail(419, 'csrf_failed', 'Your session has expired. Please reload the page.');
}
}
if ($id !== null && $id !== false && !isset($_SESSION['tasks'][$id])) {
fail(404, 'not_found', 'Task not found.');
}
switch ($method) {
case 'GET':
respond(200, ['data' => array_values($_SESSION['tasks'])]);
case 'POST':
[$data, $errors] = validateTaskInput(jsonBody());
if ($errors !== []) {
fail(422, 'validation_failed', 'Please correct the highlighted fields.', $errors);
}
$task = ['id' => $_SESSION['next_task_id']++, 'title' => $data['title'], 'done' => false];
$_SESSION['tasks'][$task['id']] = $task;
respond(201, ['data' => $task]);
case 'PATCH':
$input = jsonBody();
if (!$id || !is_array($input) || !is_bool($input['done'] ?? null)) {
fail(422, 'validation_failed', 'Send {"done": true|false} for an existing task id.');
}
$_SESSION['tasks'][$id]['done'] = $input['done'];
respond(200, ['data' => $_SESSION['tasks'][$id]]);
case 'DELETE':
if (!$id) {
fail(422, 'validation_failed', 'A task id is required.');
}
unset($_SESSION['tasks'][$id]);
respond(204);
default:
header('Allow: GET, POST, PATCH, DELETE');
fail(405, 'method_not_allowed', 'This method is not supported.');
}
Example Exchange
| Request | Status | Response |
|---|---|---|
POST api/tasks.php with {"title": "Buy notebooks"} | 201 | {"data":{"id":1,"title":"Buy notebooks","done":false}} |
POST api/tasks.php with {"title": ""} | 422 | {"error":{"code":"validation_failed","message":"Please correct the highlighted fields.","fields":{"title":"Title is required."}}} |
PATCH api/tasks.php?id=1 with {"done": true} | 200 | {"data":{"id":1,"title":"Buy notebooks","done":true}} |
GET api/tasks.php | 200 | {"data":[{"id":1,"title":"Buy notebooks","done":true}]} |
DELETE api/tasks.php?id=1 | 204 | (empty) |
PATCH api/tasks.php?id=99 with {"done": true} | 404 | {"error":{"code":"not_found","message":"Task not found."}} |
How It Works
- The HTTP method chooses the operation: GET reads, POST creates, PATCH updates, DELETE removes.
- All changing methods require the CSRF token header.
- JSON bodies are size-limited, depth-limited, and decoded with exceptions; invalid JSON yields a
400with codeinvalid_json. respond()andfail()guarantee the consistent envelope. Because both returnnever, nobreakis needed after them in theswitch.- JSON types are preserved end to end:
idis a number anddonea boolean, because PHP'sintandboolencode as JSON numbers and booleans.
The Client Wrapper
class ApiError extends Error {
constructor(status, error) {
super(error?.message ?? `Request failed with status ${status}`);
this.status = status;
this.code = error?.code ?? 'unknown';
this.fields = error?.fields ?? {};
}
}
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
async function api(method, url, body) {
const response = await fetch(url, {
method,
headers: {
Accept: 'application/json',
...(body !== undefined && { 'Content-Type': 'application/json' }),
...(method !== 'GET' && { 'X-CSRF-Token': csrfToken }),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (response.status === 204) {
return null;
}
let payload;
try {
payload = await response.json();
} catch {
throw new ApiError(response.status, { code: 'invalid_response', message: 'The server sent an unexpected response.' });
}
if (!response.ok) {
throw new ApiError(response.status, payload.error);
}
return payload.data;
}
// Usage
try {
const task = await api('POST', 'api/tasks.php', { title: titleInput.value });
addTaskToList(task);
} catch (error) {
if (error.code === 'validation_failed') {
showFieldErrors(error.fields);
} else {
showMessage(error.message);
}
}
Why a Wrapper?
- Every request sends the right headers, and every response is handled the same way.
- Errors become exceptions with
status,code,message, andfields, so UI code can react precisely (show field errors forvalidation_failed, ask to reload forcsrf_failed). - Unexpected non-JSON responses (such as a PHP fatal error page) are detected instead of causing confusing parse errors.
Common Mistakes
Inconsistent Response Shapes
Effect: Every client call needs special-case handling.
Fix: Use one envelope for all endpoints.
Returning Errors With Status 200
Effect: Clients treat failures as success.
Fix: Match status codes to outcomes.
Not Validating the Type of Decoded JSON
Effect: Warnings or TypeErrors when the body is a list, string, or number.
Fix: Check that the decoded value has the expected structure.
Losing Types
Effect: Numbers and booleans arrive as strings ("1", "true").
Fix: Keep values typed in PHP and avoid casting everything to strings before encoding.
Unlimited JSON Bodies
Effect: Memory and CPU exhaustion from huge or deeply nested payloads.
Fix: Limit body size and decoding depth.
Best Practices
- Define a consistent JSON envelope for data and errors, with machine-readable error codes.
- Use HTTP methods and status codes according to their meaning.
- Validate the structure and types of every JSON request body.
- Wrap
fetch()in a client helper that handles headers, CSRF tokens, and errors uniformly. - Keep validation and business logic in testable PHP functions or classes.
Official reference: https://www.php.net/manual/en/function.json-encode.php