Validation: Required Fields, Types, Ranges, Formats, and Cross-Field Rules

Build a thorough validation layer for API request bodies covering required fields, type checks, length and range limits, format validation, nested data structures, cross-field rules, and input normalization.

Validation is the gate between 'bytes the client sent' and 'data your business logic is allowed to trust'. Everything past the validation layer — the service, the repository, the database — should be able to assume the data it receives is well-formed, because validation already rejected anything that wasn't. Skimping on validation doesn't just produce confusing 500 errors later in the pipeline; unvalidated input is the root cause behind SQL injection, broken business invariants (negative prices, end dates before start dates), and data corruption that's expensive to clean up after the fact. This lesson works through the full checklist a serious validation layer covers: is the field present, is it the right type, is it the right length/range, does it match the right format, does nested data (arrays of objects) validate recursively, do multiple fields agree with each other, and is the value normalized into a canonical form before it's stored.

Required fields and types

The first two questions validation must answer for every field are: is it present at all, and if present, is it the right type? PHP's dynamic typing makes this easy to get wrong — $body['price'] might be an int, a string '19.99', a float, or missing entirely, and treating a missing key as null silently instead of rejecting it is a common source of bugs three layers downstream. A required-field check should fail loudly and specifically ('price is required') rather than letting a missing value flow through as 0 or null and cause a confusing failure later.

Type checks in PHP for JSON-decoded input mean checking against the types json_decode actually produces: JSON objects become associative arrays (or stdClass), JSON numbers become int/float, JSON strings become string, JSON booleans become bool, and JSON null becomes PHP null. A validator should check is_string(), is_int(), is_array(), etc. explicitly rather than relying on PHP's loose comparisons, which can accept surprising values ('0', 'abc' == 0 under old PHP versions, etc.).

Lengths, ranges, and formats

Beyond 'is it a string', you almost always need 'is it a string of a reasonable length' — a title field with no max length is an open invitation for a client to send a 10MB string and blow up your database column or your UI. Numeric fields need range checks too: a quantity should be a positive integer within some sane bound, a discount_percent should be between 0 and 100, not just 'any float'.

Format validation checks that a string, while technically the right type and length, actually looks like what it claims to be: an email address matches an email shape (PHP's filter_var($v, FILTER_VALIDATE_EMAIL) is the standard tool), a URL is a well-formed URL (FILTER_VALIDATE_URL), a date string matches YYYY-MM-DD and represents a real calendar date (not 2024-02-30), and a country code is one of an allowed ISO set rather than arbitrary text.

• Required: reject a missing key outright, don't default it silently. • Type: is_string / is_int / is_float / is_bool / is_array as appropriate for the JSON type. • Length: min/max character length for strings (e.g. title 1–200 chars). • Range: min/max for numbers (e.g. quantity >= 1, discount 0–100). • Format: filter_var for email/URL, regex or DateTime::createFromFormat for dates, an allowlist for enum-like fields.

Nested data and cross-field validation

Real API payloads are rarely flat. An order-creation request has a top-level customer_id alongside a nested items array, where each item itself has sku, qty, and price_cents fields that need their own validation. Nested validation means recursing: validate the array's shape (is it a non-empty array?), then validate every element against the item schema, and report errors with a path that identifies exactly which nested field failed (items[2].qty), not just 'something in items is wrong'.

Cross-field validation checks relationships *between* fields that no single-field rule can catch: 'end_date must be after start_date', 'discount_percent can only be set if promo_code is also present', 'password and password_confirmation must match'. These rules necessarily run after the individual fields have already passed their own checks (you can't meaningfully compare two dates if one of them isn't a valid date yet), so a validator typically runs single-field rules first, then a second pass of whole-object rules.

Normalization: cleaning input before you trust it

Normalization transforms a value into a canonical form *after* it passes validation but *before* it's used or stored — trimming leading/trailing whitespace from strings, lowercasing an email address so [email protected] and [email protected] are treated as the same account, collapsing multiple internal spaces, or converting a price string '19.99' into an integer number of cents (1999) to avoid floating-point rounding bugs in money math.

Normalization should happen in one place, applied consistently, rather than scattered — some code paths lowercasing an email and others not is exactly how you end up with two 'different' user accounts for the same real email address. A good pattern is: validate the raw shape first, then run normalization as a dedicated step that produces the clean value the rest of the app uses.

• Trim whitespace from all string inputs before length-checking or storing them. • Lowercase emails and other case-insensitive identifiers consistently everywhere. • Convert money to integer cents (or an arbitrary-precision type) rather than floats, to avoid rounding errors. • Normalize before storing, but validate the raw-ish value too (e.g. still enforce a reasonable max length pre-trim).

A small reusable field validator

Each check appends to a growing list of errors instead of throwing on the first failure, so the client gets every problem at once, not one-at-a-time. mb_strlen (not strlen) is used for length checks so multi-byte UTF-8 names/text aren't miscounted by byte length.

<?php
declare(strict_types=1);

final class ValidationError {
    public function __construct(
        public readonly string $field,
        public readonly string $code,
        public readonly string $message,
    ) {}
}

final class Validator {
    /** @var ValidationError[] */
    private array $errors = [];

    public function required(array $data, string $field): mixed {
        if (!array_key_exists($field, $data) || $data[$field] === null || $data[$field] === '') {
            $this->errors[] = new ValidationError($field, 'required', "$field is required");
            return null;
        }
        return $data[$field];
    }

    public function string(mixed $value, string $field, int $min = 0, int $max = PHP_INT_MAX): ?string {
        if (!is_string($value)) {
            $this->errors[] = new ValidationError($field, 'type', "$field must be a string");
            return null;
        }
        $len = mb_strlen($value);
        if ($len < $min || $len > $max) {
            $this->errors[] = new ValidationError($field, 'length', "$field must be $min-$max characters");
            return null;
        }
        return $value;
    }

    public function errors(): array { 
…

Code Examples

This validator runs required/type/format checks per field (email, plan, seats, dates), normalizes valid values as it goes (trimming and lowercasing the email), recurses into the nested seat_assignments array to validate each element with a per-item error path, and finally applies two cross-field rules — end date after start date, and assignment count not exceeding purchased seats — only once the individual fields they depend on have already validated. Expected result: [$clean, ['end_date' => 'end_date must be after start_date']] Using DateTimeImmutable::createFromFormat('!Y-m-d', ...) and then re-formatting and string-comparing catches PHP's lenient date parsing, which would otherwise silently roll 2024-02-30 over to 2024-03-01 instead of rejecting it as invalid.

if ($start !== null && $end !== null && $end <= $start) { $errors['end_date'] = 'end_date must be after start_date'; }

<?php
declare(strict_types=1);

final class SubscriptionCreateValidator {
    /** @return array{0: array, 1: array} [normalizedData, errors] keyed by field path */
    public static function validate(array $body): array {
        $errors = [];
        $out = [];

        // --- required + type + format: email ---
        $email = $body['email'] ?? null;
        if (!is_string($email) || trim($email) === '') {
            $errors['email'] = 'email is required';
        } elseif (!filter_var(trim($email), FILTER_VALIDATE_EMAIL)) {
            $errors['email'] = 'email must be a valid email address';
        } else {
            $out['email'] = strtolower(trim($email)); // --- normalization ---
        }

        // --- required + type + length: plan name ---
        $plan = $body['plan'] ?? null;
        $allowedPlans = ['basic', 'pro', 'enterprise'];
        if (!is_string($plan) || !in_array($plan, $allowedPlans, true)) {
            $errors['plan'] = 'plan must be one of: ' . implode(', ', $allowedPlans);
        } else {
            $out['plan'] = $plan;
        }

        // --- range: seats must be a positive int within a sane bound ---
        $seats = $body['seats'] ?? null;

…

Validation pipeline for one field vs. cross-field rules

Single-field rules run first; cross-field rules only make sense once their inputs are individually valid.

Per-field checks (independent, order doesn't matter much):
  required? ──► type? ──► length/range? ──► format? ──► normalize
     │             │            │              │            │
   missing       wrong type   too long/     not an email/  trim,
   -> error      -> error     out of range  bad date shape lowercase,
                              -> error      -> error        int cents

Cross-field checks (must run AFTER individual fields pass):
  start_date ✓  end_date ✓  ──►  end_date > start_date?  ──► error/ok
  seats ✓  seat_assignments[] ✓ ──► len(assignments) <= seats? ──► error/ok

Important Rules

Reject missing required fields explicitly — never let a missing key silently become null/0 downstream.

Check JSON-decoded types explicitly (is_string/is_int/is_array) rather than relying on loose PHP comparisons.

Always bound string length and numeric range — 'any string'/'any number' is rarely actually valid business input.

Use filter_var (FILTER_VALIDATE_EMAIL/URL) or strict DateTime parsing for formats, not ad-hoc regexes for well-known formats.

Validate nested arrays element-by-element and report errors with a path (e.g. items[2].qty), not a single generic message.

Run cross-field rules only after the individual fields they reference have already passed validation.

Normalize (trim, lowercase, convert money to integer cents) in one consistent place, applied to every request the same way.

Why this matters at scale

Frameworks like Laravel (Form Requests + Rule objects) and Symfony (the Validator component with constraint annotations) exist specifically to make this checklist declarative instead of hand-rolled per endpoint — but they implement the exact same categories covered here: required, type, length/range, format, nested, cross-field ('confirmed', 'after:start_date'), and normalization hooks. Understanding the manual version makes the framework's declarative rules easy to read and debug when validation behaves unexpectedly.

if ($body['end_date'] < $body['start_date']) { ... } // compares before either is validated as a real date

Validate start_date and end_date individually first; only compare them once both parsed successfully

Comparing raw, unvalidated strings can silently 'pass' malformed input (string comparison of dates doesn't behave like date comparison) or throw when a field is missing entirely — cross-field logic must run after per-field validation, not instead of it.

Storing prices as float dollars ($price = 19.99) and doing arithmetic on them

Normalize to integer cents ($priceCents = 1999) and do all arithmetic in integers

Floating-point arithmetic on money accumulates rounding errors (0.1 + 0.2 !== 0.3 in IEEE 754) that eventually produce off-by-a-cent totals; normalizing to integer cents at the validation boundary avoids the whole class of bug.

Why must cross-field validation (like 'end_date after start_date') run after individual field validation? A. It doesn't matter, order is irrelevant B. Because comparing values that haven't been confirmed to be valid dates/numbers yet can produce meaningless or crashing comparisons C. PHP requires validators to run in alphabetical field order D. Cross-field rules are always faster to run first

B. Because comparing values that haven't been confirmed to be valid dates/numbers yet can produce meaningless or crashing comparisons — You can't meaningfully compare two dates for ordering if one of them isn't confirmed to be a valid date yet — cross-field rules depend on their inputs already being individually valid, so they run in a second pass.

What is the main reason to normalize prices to integer cents instead of storing them as PHP floats? A. Integers take less memory B. Floating-point arithmetic introduces rounding errors that cause incorrect totals in money math C. PHP doesn't support float multiplication D. JSON can't encode floats

B. Floating-point arithmetic introduces rounding errors that cause incorrect totals in money math — IEEE 754 floating-point numbers can't represent many decimal fractions exactly, so repeated arithmetic on float dollar amounts accumulates rounding errors — integer cents avoids this entirely.

What should a validator do with a nested array field like order.items? A. Skip validating it since nested data is too complex B. Only check that it's an array and stop there C. Recurse into each element, validating its own fields and reporting errors with a path like items[2].qty D. Convert it to a string and validate the string

C. Recurse into each element, validating its own fields and reporting errors with a path like items[2].qty — Nested structures need element-by-element validation just like top-level fields, and errors should identify exactly which nested element and field failed so the client can fix the right thing.

Write validation rules (in plain language or pseudocode) for a 'create event' API: title (required, 1-120 chars), starts_at and ends_at (required dates, ends_at must be after starts_at), capacity (optional positive integer, default 100 if absent), and attendees (a nested array of {name, email}, at most capacity entries).

title: required, is_string, mb_strlen 1-120. starts_at/ends_at: required, valid date format, then cross-field ends_at > starts_at. capacity: optional, if absent default 100, else is_int and >= 1. attendees: required array; each element requires a non-empty name and a FILTER_VALIDATE_EMAIL-valid email; cross-field: count(attendees) <= capacity (using either the provided or defaulted value).

Separate per-field checks from the two cross-field rules: ends_at > starts_at, and count(attendees) <= capacity.