http clients and apis
JSON Validation
JSON validation checks whether decoded JSON has the fields, types, and values your application expects. Valid JSON syntax is not enough.
An API request can be valid JSON and still be wrong for your endpoint: missing email, string where an integer is required, unsupported status, empty array, or business rules that do not pass.
Validate fields after decoding
<?php
declare(strict_types=1);
$payload = ['email' => 'sam@example.com', 'age' => 32];
$errors = [];
if (!filter_var($payload['email'] ?? '', FILTER_VALIDATE_EMAIL)) {
$errors['email'] = 'A valid email is required.';
}
if (!is_int($payload['age'] ?? null) || $payload['age'] < 18) {
$errors['age'] = 'Age must be 18 or over.';
}
echo $errors === [] ? 'JSON payload is valid' : json_encode($errors, JSON_THROW_ON_ERROR);
// Prints:
// JSON payload is valid
Decoding JSON only proves the syntax is valid. You still need to validate the fields, types, required values, and business rules before using the payload.
Required fields and types
Use explicit checks. isset() is useful, but remember it returns false for null. That can be good or bad depending on whether null is allowed.
<?php
declare(strict_types=1);
/**
* @param array<string, mixed> $payload
* @return array<string, string>
*/
function validateProductPayload(array $payload): array
{
$errors = [];
if (!isset($payload['name']) || !is_string($payload['name']) || trim($payload['name']) === '') {
$errors['name'] = 'Name is required.';
}
if (!isset($payload['price']) || !is_float($payload['price']) && !is_int($payload['price'])) {
$errors['price'] = 'Price must be a number.';
}
return $errors;
}
echo json_encode(validateProductPayload(['name' => '', 'price' => '9.99']), JSON_THROW_ON_ERROR) . PHP_EOL;
// Prints:
// {"name":"Name is required.","price":"Price must be a number."}
This kind of validation should happen near the API boundary, before the data reaches business logic.
Normalise before business checks
Normalisation prepares values for validation and use. For example, trim names and lower-case email addresses where the application treats them case-insensitively.
<?php
declare(strict_types=1);
function normaliseEmail(string $email): string
{
return strtolower(trim($email));
}
echo normaliseEmail(' SAM@Example.COM ') . PHP_EOL;
// Prints:
// sam@example.com
Do not normalise in ways that lose meaningful data. A product code, password, or external ID may be case-sensitive.
Business rules
Some rules are about meaning, not type. A value can be an integer but still out of range.
<?php
declare(strict_types=1);
function validateQuantity(int $quantity): ?string
{
if ($quantity < 1 || $quantity > 100) {
return 'Quantity must be between 1 and 100.';
}
return null;
}
echo validateQuantity(200) ?? 'Quantity is valid';
// Prints:
// Quantity must be between 1 and 100.
Nested structures
Nested JSON needs nested validation. If an order contains items, validate each item and include a useful error location.
<?php
declare(strict_types=1);
/**
* @param array<string, mixed> $payload
* @return list<array{field: string, message: string}>
*/
function validateOrder(array $payload): array
{
$errors = [];
$items = $payload['items'] ?? null;
if (!is_array($items) || $items === []) {
return [['field' => 'items', 'message' => 'At least one item is required.']];
}
foreach ($items as $index => $item) {
if (!is_array($item) || !isset($item['sku']) || !is_string($item['sku'])) {
$errors[] = ['field' => 'items.' . $index . '.sku', 'message' => 'SKU is required.'];
}
}
return $errors;
}
echo json_encode(validateOrder(['items' => [['quantity' => 2]]]), JSON_THROW_ON_ERROR) . PHP_EOL;
// Prints:
// [{"field":"items.0.sku","message":"SKU is required."}]
Unknown fields
Decide whether unknown fields are ignored or rejected. Ignoring them is flexible. Rejecting them can catch client mistakes early. For public APIs, document the behaviour.
Error response shape
Validation failures usually use a 4xx status, often 422 Unprocessable Content, with field-level errors.
<?php
declare(strict_types=1);
$response = [
'errors' => [
['field' => 'email', 'message' => 'A valid email is required.'],
],
];
echo json_encode($response, JSON_THROW_ON_ERROR) . PHP_EOL;
// Prints:
// {"errors":[{"field":"email","message":"A valid email is required."}]}
What to check in a project
Check that decoded JSON is validated before being used.
Check required fields, field types, allowed values, ranges, and nested collections.
Check whether null is allowed for each field.
Check validation errors are useful to clients without exposing internals.
Check whether unknown fields are handled consistently.
What you should be able to do
After this lesson, you should be able to validate required JSON fields, check types and business rules, validate nested structures, return useful field errors, and separate JSON syntax errors from payload validation errors.
Practice
Task: Validate A Product Payload
Write a small PHP script that validates a decoded product JSON payload.
Requirements
- Use
declare(strict_types=1);. - Require
nameas a non-empty string. - Require
priceas an integer or float greater than zero. - Allow
tagsonly when it is a list of strings. - Return field-level errors.
- Include one valid payload and one invalid payload.
- Print both results.
Check Your Work
Run the script and confirm valid JSON with wrong field types is rejected.
Show solution
This solution validates a decoded array, not the raw JSON string. Decoding and validation are separate steps.
<?php
declare(strict_types=1);
/**
* @param array<string, mixed> $payload
* @return list<array{field: string, message: string}>
*/
function validateProduct(array $payload): array
{
$errors = [];
if (!isset($payload['name']) || !is_string($payload['name']) || trim($payload['name']) === '') {
$errors[] = ['field' => 'name', 'message' => 'Name is required.'];
}
$price = $payload['price'] ?? null;
if ((!is_int($price) && !is_float($price)) || $price <= 0) {
$errors[] = ['field' => 'price', 'message' => 'Price must be greater than zero.'];
}
if (array_key_exists('tags', $payload)) {
if (!is_array($payload['tags'])) {
$errors[] = ['field' => 'tags', 'message' => 'Tags must be a list of strings.'];
} else {
foreach ($payload['tags'] as $index => $tag) {
if (!is_string($tag)) {
$errors[] = ['field' => 'tags.' . $index, 'message' => 'Tag must be a string.'];
}
}
}
}
return $errors;
}
$valid = ['name' => 'Notebook', 'price' => 9.99, 'tags' => ['paper']];
$invalid = ['name' => '', 'price' => 'free', 'tags' => ['paper', 123]];
echo json_encode(validateProduct($valid), JSON_THROW_ON_ERROR) . PHP_EOL;
echo json_encode(validateProduct($invalid), JSON_THROW_ON_ERROR) . PHP_EOL;
// Prints:
// []
// [{"field":"name","message":"Name is required."},{"field":"price","message":"Price must be greater than zero."},{"field":"tags.1","message":"Tag must be a string."}]
Why This Works
The valid payload returns no errors. The invalid payload proves the validator catches missing content, wrong scalar types, and nested list item types.