Data Types And Standard Library
JSON
In PHP, JSON work has two separate steps. First, decode the JSON text into PHP data. Second, validate that the decoded data has the shape your code expects. Successful decoding only proves the text was valid JSON. It does not prove the payload is safe, complete, authorized, meaningful, or useful for the operation.
Treat JSON as an application boundary. Boundary code should be explicit: how decoding errors are reported, whether objects become arrays or objects, which fields are required, how null is handled, how large identifiers are represented, and which encoding options are used for output.
Decode with exceptions
Use JSON_THROW_ON_ERROR so invalid JSON becomes an exception instead of a silent null.
<?php
declare(strict_types=1);
$rawJson = '{"items":["Notebook","Pen"]}';
$payload = json_decode($rawJson, true, flags: JSON_THROW_ON_ERROR);
echo $payload['items'][0] . PHP_EOL;
// Prints:
// Notebook
The second argument, true, tells PHP to decode JSON objects as associative arrays. That is usually the easiest shape to validate in application code because you can use array checks, named keys, and small validation helpers.
Do not assume that invalid JSON will be rare enough to ignore. Broken request bodies, truncated queue messages, copied fixture files, and webhook retries all happen. If decoding returns null for an error and your code also accepts JSON null, the failure path becomes ambiguous. Exceptions make malformed JSON a distinct failure.
Validate the decoded shape
Do not assume keys exist just because decoding succeeded.
<?php
declare(strict_types=1);
function itemCountFromJson(string $rawJson): int
{
$payload = json_decode($rawJson, true, flags: JSON_THROW_ON_ERROR);
if (!is_array($payload)) {
throw new InvalidArgumentException('Payload must be a JSON object.');
}
if (!isset($payload['items']) || !is_array($payload['items'])) {
throw new InvalidArgumentException('Payload must contain an items array.');
}
return count($payload['items']);
}
echo itemCountFromJson('{"items":["Notebook","Pen"]}') . PHP_EOL;
// Prints:
// 2
This is the boundary where many JSON bugs appear. JSON can be valid while still being the wrong top-level type, missing required fields, containing null where a string is required, using an empty list where at least one item is needed, or carrying values from an older API version.
Validate close to the boundary and return a cleaner internal shape. If a downstream function expects a non-empty customer email and a list of item SKUs, it should not also be responsible for wondering whether the raw payload was a string, object, array, or null.
Missing keys, null, and empty values
JSON has a real null value. A missing key, a present key with null, a present key with an empty string, and a present key with an empty array can all mean different things. Your validation code should say which cases are allowed.
Use array_key_exists() when a key is allowed to exist with null and you need to distinguish that from a missing key. Use isset() when null should behave like missing.
<?php
declare(strict_types=1);
$payload = json_decode('{"nickname":null}', true, flags: JSON_THROW_ON_ERROR);
var_dump(array_key_exists('nickname', $payload));
var_dump(isset($payload['nickname']));
// Prints:
// bool(true)
// bool(false)
For required text fields, check both type and content. A valid JSON string can still be empty or contain only spaces. For lists, decide whether an empty list is meaningful or should be rejected.
Handle invalid JSON separately from invalid data
Malformed JSON and valid-but-wrong JSON are different failures.
<?php
declare(strict_types=1);
function decodeObject(string $rawJson): array
{
try {
$payload = json_decode($rawJson, true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new InvalidArgumentException('Request body is not valid JSON.', 0, $exception);
}
if (!is_array($payload)) {
throw new InvalidArgumentException('Request body must be a JSON object.');
}
return $payload;
}
try {
decodeObject('{"name":');
} catch (InvalidArgumentException $exception) {
echo $exception->getMessage() . PHP_EOL;
}
// Prints:
// Request body is not valid JSON.
This distinction helps API clients fix the right problem and helps logs tell a clearer story. A malformed body might map to a generic bad-request error. A valid JSON object with a missing required business field might map to a validation error that names the field.
Do not leak raw payloads into user-facing errors. They may contain secrets or personal data. Logs can include correlation IDs, source names, and safe summaries while keeping the response concise.
Encode response data
Use json_encode() for outgoing JSON and throw on errors there too.
<?php
declare(strict_types=1);
$response = [
'ok' => true,
'itemCount' => 2,
];
echo json_encode($response, JSON_THROW_ON_ERROR) . PHP_EOL;
// Prints:
// {"ok":true,"itemCount":2}
In a web controller, you would also set the Content-Type: application/json header and the correct HTTP status code. The data shape and the transport details are both part of a reliable API response.
Encoding can fail when values cannot be represented as JSON, such as invalid UTF-8 strings or unsupported types. Throwing on encode errors prevents a response helper from returning false and accidentally emitting an empty or misleading body.
Preserve large identifiers
Some systems send large numeric IDs that do not fit safely into normal integer handling across languages. If an ID is not used for maths, treat it as a string.
<?php
declare(strict_types=1);
$rawJson = '{"externalId":9223372036854775808}';
$payload = json_decode($rawJson, true, flags: JSON_THROW_ON_ERROR | JSON_BIGINT_AS_STRING);
echo gettype($payload['externalId']) . PHP_EOL;
echo $payload['externalId'] . PHP_EOL;
// Prints:
// string
// 9223372036854775808
IDs, account numbers, order references, and tracking numbers should usually be strings even when they contain only digits. They are identifiers, not quantities. Keeping them as strings also avoids accidental numeric formatting, leading-zero loss, and cross-language precision problems.
If you control the API contract, prefer sending identifiers as JSON strings from the start. If a provider sends large numbers, document the decode option and validate the resulting PHP type before storing or comparing the value.
Use clear output options
Pretty printing is useful for logs, fixtures, examples, and generated files. Compact JSON is usually better for API responses.
<?php
declare(strict_types=1);
$payload = [
'status' => 'accepted',
'items' => ['Notebook', 'Pen'],
];
echo json_encode($payload, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT) . PHP_EOL;
// Prints:
// {
// "status": "accepted",
// "items": [
// "Notebook",
// "Pen"
// ]
// }
Choose options because the output is consumed somewhere specific, not because one format looks nicer in isolation. Fixtures and logs benefit from readability. HTTP responses usually benefit from compactness. Some APIs also require a stable encoding policy for slashes, Unicode, or numeric values.
Do not build JSON by concatenating strings. Always encode data structures. String concatenation is easy to break with quotes, backslashes, control characters, or user input. Encoding lets PHP apply JSON escaping rules consistently.
Arrays, objects, and top-level values
JSON top-level values can be objects, arrays, strings, numbers, booleans, or null. Many APIs require a top-level object, but JSON itself does not. If your endpoint expects an object, validate that explicitly.
When decoding with associative arrays, both JSON objects and JSON arrays become PHP arrays. You still need to validate the shape. A list of items and an object of named fields are not interchangeable just because both are arrays in PHP. A list should have sequential numeric indexes; a record should have meaningful string keys.
When encoding PHP arrays, PHP chooses JSON array or JSON object based on the keys. Sequential zero-based integer keys encode as a JSON array. Non-sequential or string keys encode as a JSON object. If you filter a list and preserve original indexes, call array_values() before encoding when the response contract says the field is a JSON array.
Security and operational limits
JSON decoding is not authorization. A payload can be well formed, have every required field, and still come from the wrong client or describe an action the caller is not allowed to perform. Authentication, signature checks, CSRF protection, authorization, and replay protection belong around the JSON handler when the boundary requires them.
Size limits matter too. An endpoint that accepts JSON should have a maximum request body size before decoding. Deeply nested or extremely large payloads can consume memory and CPU even when they are valid JSON. Web servers, framework middleware, queue consumers, and CLI import tools should all have a documented limit that matches the use case.
Be careful with logs. Raw JSON bodies can contain passwords, tokens, addresses, payment references, and other sensitive data. Log safe metadata such as request IDs, provider event IDs, payload sizes, and validation error codes. If you need to retain payloads for debugging or replay, store them under the same access controls and retention rules as other sensitive application data.
For webhooks and queue messages, idempotency is part of reliable JSON handling. A decoded payload may be delivered more than once. Validate the event identifier, store processing state, and make repeated deliveries harmless where the provider contract expects retries. That concern is outside json_decode(), but it belongs to the same boundary design.
Testing JSON boundaries
JSON tests should cover malformed JSON and valid JSON with invalid data. Include wrong top-level types, missing required fields, null for required fields, empty strings, empty arrays, large identifiers, extra fields, and values with characters that need JSON escaping.
For response helpers, assert the decoded response shape rather than only comparing raw strings, unless exact formatting is part of the contract. For fixtures, pretty-printed output can be compared directly because readability and stable formatting are the purpose.
Keep sample payloads small but realistic. A test with {} is useful for missing fields, but it does not prove that a realistic order, webhook, or API response is accepted. Pair minimal failure cases with one representative success case.
Review checklist
Before approving JSON code, identify the boundary: request, response, webhook, queue message, fixture, or config file. Confirm decoding uses JSON_THROW_ON_ERROR, malformed JSON is handled separately from invalid data, required fields are validated by type and content, IDs are modeled as strings where appropriate, and output is produced by json_encode() rather than manual concatenation.
Also check list keys before encoding. A PHP array that looks like a list during processing can encode as a JSON object if its indexes are sparse. That is a subtle API regression and a common reason to use array_values() after filtering a list.
What to remember
JSON is an application boundary. Decode with exceptions, validate the decoded shape, distinguish missing from null when it matters, treat external IDs carefully, encode responses deliberately, and keep invalid JSON separate from invalid business data. A reliable JSON handler should be boring to review because every assumption is visible.
Practice
Task: Decode and validate an order request
Write a function that accepts a raw JSON request body for a simple order.
Requirements
- Use
declare(strict_types=1);. - Decode with
JSON_THROW_ON_ERROR. - Require the decoded value to be a JSON object.
- Require
customerEmailto be a non-empty string. - Require
itemsto be a non-empty array. - Return a small response array containing
acceptedanditemCount. - Encode and print the response JSON.
- Show one invalid JSON case or invalid data case by catching the exception.
- Include the expected output as comments in the same PHP code block.
The task should prove both parts of JSON handling: decoding text and validating the data shape.
Show solution
<?php
declare(strict_types=1);
function acceptOrderRequest(string $rawJson): array
{
try {
$payload = json_decode($rawJson, true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new InvalidArgumentException('Request body is not valid JSON.', 0, $exception);
}
if (!is_array($payload)) {
throw new InvalidArgumentException('Request body must be a JSON object.');
}
if (!isset($payload['customerEmail']) || !is_string($payload['customerEmail']) || trim($payload['customerEmail']) === '') {
throw new InvalidArgumentException('Customer email is required.');
}
if (!isset($payload['items']) || !is_array($payload['items']) || $payload['items'] === []) {
throw new InvalidArgumentException('At least one item is required.');
}
return [
'accepted' => true,
'itemCount' => count($payload['items']),
];
}
$response = acceptOrderRequest('{"customerEmail":"nia@example.com","items":["Notebook","Pen"]}');
echo json_encode($response, JSON_THROW_ON_ERROR) . PHP_EOL;
try {
acceptOrderRequest('{"customerEmail":"","items":[]}');
} catch (InvalidArgumentException $exception) {
echo $exception->getMessage() . PHP_EOL;
}
// Prints:
// {"accepted":true,"itemCount":2}
// Customer email is required.
The function treats JSON parsing and payload validation as separate responsibilities. That makes it clear whether a request failed because the body was not JSON or because the JSON did not contain the fields the application needs.
Task: Decode a webhook with a large external ID
Write a helper for a webhook payload that contains an external ID and event type.
Requirements
- Use
declare(strict_types=1);. - Decode with
JSON_THROW_ON_ERRORandJSON_BIGINT_AS_STRING. - Require the decoded value to be a JSON object.
- Require
externalIdto be a non-empty string after decoding. - Require
eventTypeto be a non-empty string. - Return a normalized array containing
externalIdandeventType. - Print the normalized values for a valid payload with a very large numeric ID.
- Include one invalid data case and print the exception message.
- Include expected output comments in the PHP code block.
Show solution
<?php
declare(strict_types=1);
function decodeWebhook(string $rawJson): array
{
try {
$payload = json_decode($rawJson, true, flags: JSON_THROW_ON_ERROR | JSON_BIGINT_AS_STRING);
} catch (JsonException $exception) {
throw new InvalidArgumentException('Webhook body is not valid JSON.', 0, $exception);
}
if (!is_array($payload)) {
throw new InvalidArgumentException('Webhook body must be a JSON object.');
}
if (!isset($payload['externalId']) || !is_string($payload['externalId']) || $payload['externalId'] === '') {
throw new InvalidArgumentException('Webhook externalId is required.');
}
if (!isset($payload['eventType']) || !is_string($payload['eventType']) || trim($payload['eventType']) === '') {
throw new InvalidArgumentException('Webhook eventType is required.');
}
return [
'externalId' => $payload['externalId'],
'eventType' => trim($payload['eventType']),
];
}
$webhook = decodeWebhook('{"externalId":9223372036854775808,"eventType":"order.paid"}');
echo $webhook['externalId'] . ' ' . $webhook['eventType'] . PHP_EOL;
try {
decodeWebhook('{"externalId":"","eventType":"order.paid"}');
} catch (InvalidArgumentException $exception) {
echo $exception->getMessage() . PHP_EOL;
}
// Prints:
// 9223372036854775808 order.paid
// Webhook externalId is required.
The helper keeps the external identifier as a string and validates the decoded shape before returning a normalized internal array.
Task: Encode a filtered JSON response list
Build a response helper that returns only active product names as JSON.
Requirements
- Use
declare(strict_types=1);. - Start with at least three product records containing
nameandactive. - Filter to active products.
- Reindex the filtered list before encoding so the JSON field is an array, not an object.
- Return compact JSON with
JSON_THROW_ON_ERROR. - Print the response JSON.
- Include expected output comments in the PHP code block.
Show solution
<?php
declare(strict_types=1);
function activeProductsResponse(array $products): string
{
$activeProducts = array_values(array_filter($products, function (array $product): bool {
return ($product['active'] ?? false) === true;
}));
$names = array_map(function (array $product): string {
return $product['name'];
}, $activeProducts);
return json_encode(['activeProducts' => $names], JSON_THROW_ON_ERROR);
}
$products = [
['name' => 'Keyboard', 'active' => true],
['name' => 'Mouse', 'active' => false],
['name' => 'Monitor', 'active' => true],
];
echo activeProductsResponse($products) . PHP_EOL;
// Prints:
// {"activeProducts":["Keyboard","Monitor"]}
array_filter() preserves the original indexes, so the helper calls array_values() before building the response. That keeps the encoded activeProducts field as a JSON array.