Skip to content

How-to: Nested JSON Validation ​

FT reference: FT322 (NENE2-FT/nestedlog) — Order API with nested items validation, items.N.field error paths, multi-error single response, error codes, total calculation, 19 tests / 43 assertions PASS.

This guide shows how to validate nested JSON arrays (e.g. order line items) and return structured error paths that identify exactly which nested field failed.

Schema ​

sql
CREATE TABLE orders (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    customer   TEXT    NOT NULL,
    total      REAL    NOT NULL DEFAULT 0.0,
    created_at TEXT    NOT NULL
);

CREATE TABLE order_items (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    order_id   INTEGER NOT NULL REFERENCES orders(id),
    product_id INTEGER NOT NULL,
    quantity   INTEGER NOT NULL,
    unit_price REAL    NOT NULL
);

Endpoints ​

MethodPathDescription
POST/ordersCreate order with items
GET/ordersList orders
GET/orders/{id}Get order with items

Create Order ​

php
POST /orders
{
  "customer": "Alice",
  "items": [
    {"product_id": 1, "quantity": 2, "unit_price": 9.99},
    {"product_id": 2, "quantity": 1, "unit_price": 4.50}
  ]
}
→ 201
{
  "id": 1,
  "customer": "Alice",
  "items": [...],
  "total": 24.48      // 2×9.99 + 1×4.50
}

Nested Error Paths — items.N.field ​

Each item error includes the array index in the field path:

php
POST /orders
{
  "customer": "Alice",
  "items": [
    {"product_id": "not-an-int", "quantity": 2, "unit_price": 9.99},
    {"product_id": 1, "quantity": 1, "unit_price": -5.0}
  ]
}
→ 422
{
  "errors": [
    {"field": "items.0.product_id", "message": "...", "code": "invalid-type"},
    {"field": "items.1.unit_price",  "message": "...", "code": "min-value"}
  ]
}

All Errors in One Response ​

All validation failures — both top-level and nested — are collected and returned together. Never return one error at a time for batch submissions:

php
POST /orders
{
  "customer": "",      // error: required
  "items": [
    {"product_id": 0, "quantity": -1, "unit_price": 1.0}  // 2 errors
  ]
}
→ 422
{
  "errors": [
    {"field": "customer",          "code": "required"},
    {"field": "items.0.product_id","code": "min-value"},
    {"field": "items.0.quantity",  "code": "min-value"}
  ]
}

Validation Rules ​

FieldRule
customerRequired, non-blank, max 200 chars
itemsRequired, non-empty array
items[].product_idInteger, ≥ 1
items[].quantityInteger, ≥ 1
items[].unit_priceNumber (int or float), > 0

Implementation Pattern ​

php
final class OrderValidator
{
    /** @return list<ValidationError> */
    public function validate(array $data): array
    {
        $errors = [];

        // Top-level validation
        $customer = trim($data['customer'] ?? '');
        if ($customer === '') {
            $errors[] = new ValidationError('customer', 'required', 'required');
        } elseif (strlen($customer) > 200) {
            $errors[] = new ValidationError('customer', 'max 200 chars', 'max-length');
        }

        $items = $data['items'] ?? null;
        if (!is_array($items) || $items === []) {
            $errors[] = new ValidationError('items', 'required non-empty array', 'required');
            return $errors;  // can't validate items further
        }

        // Nested item validation with index
        foreach ($items as $i => $item) {
            $prefix = "items.{$i}";

            $productId = $item['product_id'] ?? null;
            if (!is_int($productId) || $productId < 1) {
                $errors[] = new ValidationError("{$prefix}.product_id", 'must be int >= 1', 'min-value');
            }

            $quantity = $item['quantity'] ?? null;
            if (!is_int($quantity) || $quantity < 1) {
                $errors[] = new ValidationError("{$prefix}.quantity", 'must be int >= 1', 'min-value');
            }

            $price = $item['unit_price'] ?? null;
            if ((!is_int($price) && !is_float($price)) || $price <= 0) {
                $errors[] = new ValidationError("{$prefix}.unit_price", 'must be number > 0', 'min-value');
            }
        }

        return $errors;
    }
}

Error Codes ​

CodeMeaning
requiredField is missing or blank
max-lengthExceeds maximum length
min-valueBelow minimum value (int/float)
invalid-typeWrong type (e.g. string where int expected)

What NOT to do ​

Anti-patternRisk
Return only first errorClient must submit, get error, fix, resubmit N times — terrible UX for batch forms
Flat error path "product_id" for nested itemsClient cannot identify which item (index 0, 1, ...) failed
Accept unit_price: 0 silentlyZero-price items corrupt order totals
Validate items only after top-level passesDelays feedback; collect all errors in one pass
Stop validation on first item errorMask further errors in remaining items

Released under the MIT License.