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 example
<?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 example
<?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 example
<?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 example
<?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 example
<?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 example
<?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 name as a non-empty string.
  • Require price as an integer or float greater than zero.
  • Allow tags only 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 example
<?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.