Three validation boundaries
In a fictional operations service, a task-creation request requires an object with a textual batchId and integer attempts between zero and five. First check whether the body is valid JSON; next whether its root is an object; finally whether its fields meet the contract. A null document is valid JSON but does not represent this request. Negative attempts can have the right type while violating the range. Build an input table covering a valid object, an array, a missing field, explicit null, and an out-of-range number. Decide expected outcomes before implementation. This separation helps support distinguish a transport problem from a rejected business rule.
Identity before convenience
Identifier 00042 belongs to an external system and its zeros are part of its identity. Keep it as a string; converting to integer and back to text does not recover the original representation. For a JSON integer beyond PHP capacity, JSON_BIGINT_AS_STRING avoids first converting it into an approximate float. This alone does not resolve consumer differences: a browser can have different limits, so a textual-ID contract is clearer. A task list has another problem: removing items can leave nonconsecutive keys. Reindex only when they are positions without meaning. If keys are task IDs, retain them under an explicitly declared object shape.
Results and errors belong to the same call
JSON_THROW_ON_ERROR allows invalid syntax to be handled without confusing failure with the JSON null value. Use a consistent model per operation: catch JsonException or inspect the state of the call without that flag. Mixing models can cause support to read an old global error after a successful operation. If fields are needed, decode once; json_validate before json_decode repeats parsing. json_validate exists from PHP 8.3 and is useful when only syntactic validity matters. For responses, decide beforehand whether invalid UTF-8 bytes require rejection or documented substitution. A rejection policy should not enable partial output, because that changes the guarantee promised to the consumer.
Catch without hiding defects
An input-format failure from a client and a TypeError caused by the program need different diagnoses. JsonException is an Exception; TypeError belongs to the Error family. Throwable covers both, but catching everything and returning success would erase that distinction. At an HTTP adapter, translate expected input errors into a stable response and retain a correlation identifier for investigation. Do not expose the full body or internal details merely to simplify diagnosis. Place specific catches before generic ones. finally serves cleanup during normal block exit; a return there can replace the pending result. Do not present it as a guarantee against abrupt process termination or machine failure.
Request laboratory
The example below accepts input whose size is bounded by the caller and validates a small contract. First predict the valid request result: batchId remains 00042 and attempts remains zero. Then replace the body with [], null, an object missing attempts, attempts as a string, and an out-of-range integer. None should automatically become a valid task. Execute each change separately and compare the failure class. The example does not implement authentication, authorization, or persistence; those controls belong in the application flow. For a real endpoint, also document the unknown-field policy and enforce a body limit before decoding.
Summary and support application
When an integration starts rejecting requests after a change, collect a synthetic sample reproducing its shape without customer data. Compare previous and current structure: root type, textual identity, fields, types, and range. If failure appears only in the response, also inspect values not representable in JSON, such as NAN, and invalid text encoding. Do not resolve a contract failure by adding casts until a test passes. Preserve the case as a regression test with its expected outcome. Connect this lesson to strict comparisons, resource authorization, and transactions: crossing the JSON boundary makes data interpretable, but neither authorizes nor commits the operation.
<?php
declare(strict_types=1);
function parseTask(string $body): array {
$value = json_decode($body, false, 32, JSON_THROW_ON_ERROR);
if (!$value instanceof stdClass) {
throw new InvalidArgumentException('Expected an object');
}
if (!property_exists($value, 'batchId') ||!is_string($value->batchId)
|| $value->batchId === '') {
throw new InvalidArgumentException('Expected a textual batchId');
}
if (!property_exists($value, 'attempts') ||!is_int($value->attempts)
|| $value->attempts < 0 || $value->attempts > 5) {
throw new InvalidArgumentException('Expected attempts from 0 to 5');
}
return ['batchId' => $value->batchId, 'attempts' => $value->attempts];
}
echo json_encode(parseTask('{"batchId":"00042","attempts":0}'), JSON_THROW_ON_ERROR)An integration replaces an empty object with an empty list. Syntax remains valid, but the parser rejects the root before attempting task creation.
Common pitfalls
Avoid treating valid null as a syntax failure, converting IDs to numbers, ignoring serialization failures, or returning success after a TypeError.
Related topics: Explicit types and comparisons · Intentional arrays and functions · PDO transactions and partial failures
Decoding does not validate business rules: retain identity, declare shape, and attach each error to the operation that produced it.
Reference: PHP manual: json_decode · PHP 8.5 reference; DR PHP 2026.2; new fixtures executed on PHP 8.3.17