JSON #
JSON (JavaScript Object Notation) is the data interchange format dominating the modern web ecosystem — almost every REST API, webhook, and microservice communicates via JSON. PHP provides two main functions, json_encode() and json_decode(), which look simple but hide many important details: error handling that’s often ignored, flags that significantly change output, the difference between decoding to objects vs arrays, Unicode character encoding, and how JsonSerializable works to give you full control over an object’s JSON representation. This article covers all aspects of JSON in PHP in depth, including streaming for large datasets and patterns that make REST API code more reliable.
json_encode() — PHP to JSON
#
json_encode() converts PHP values into a JSON string. It returns false if encoding fails — and this happens more often than you’d think:
<?php
// Basic data types
echo json_encode(42); // 42
echo json_encode(3.14); // 3.14
echo json_encode(true); // true
echo json_encode(false); // false
echo json_encode(null); // null
echo json_encode("Hello"); // "Hello"
// Arrays
echo json_encode([1, 2, 3]); // [1,2,3]
echo json_encode(['a', 'b']); // ["a","b"]
// Associative array → JSON object
echo json_encode(['name' => 'Budi', 'age' => 28]);
// {"name":"Budi","age":28}
// Multidimensional
$data = [
'user' => ['id' => 1, 'name' => 'Budi'],
'orders' => [
['id' => 101, 'total' => 150000],
['id' => 102, 'total' => 75000],
],
];
echo json_encode($data);
// {"user":{"id":1,"name":"Budi"},"orders":[{"id":101,"total":150000},{"id":102,"total":75000}]}
Important json_encode() Flags
#
<?php
$data = [
'name' => 'Budi Santoso',
'city' => 'Jakarta',
'url' => 'https://example.com/products?id=1&cat=2',
'html' => '<b>Bold</b>',
'price' => 150000.5,
];
// JSON_PRETTY_PRINT — format with indentation (for debug/human-readable APIs)
echo json_encode($data, JSON_PRETTY_PRINT);
/*
{
"name": "Budi Santoso",
"city": "Jakarta",
"url": "https:\/\/example.com\/products?id=1&cat=2",
"html": "\u003Cb\u003EBold\u003C\/b\u003E",
"price": 150000.5
}
*/
// JSON_UNESCAPED_UNICODE — Unicode characters aren't escaped
$unicode = ['emoji' => '😀', 'arab' => 'مرحبا'];
echo json_encode($unicode);
// {"emoji":"\ud83d\ude00","arab":"\u0645\u0631\u062d\u0628\u0627"}
echo json_encode($unicode, JSON_UNESCAPED_UNICODE);
// {"emoji":"😀","arab":"مرحبا"}
// JSON_UNESCAPED_SLASHES — slashes aren't escaped (useful for URLs)
echo json_encode(['url' => 'https://example.com']);
// {"url":"https:\/\/example.com"}
echo json_encode(['url' => 'https://example.com'], JSON_UNESCAPED_SLASHES);
// {"url":"https://example.com"}
// JSON_THROW_ON_ERROR — throw an exception instead of returning false (PHP 7.3+)
// This is the recommended approach — always use it
echo json_encode($data, JSON_THROW_ON_ERROR);
// Combine several flags with the bitwise | operator
$flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
echo json_encode($data, $flags);
// JSON_FORCE_OBJECT — force a numeric array to become an object
echo json_encode([1, 2, 3]);
// [1,2,3]
echo json_encode([1, 2, 3], JSON_FORCE_OBJECT);
// {"0":1,"1":2,"2":3}
// JSON_NUMERIC_CHECK — convert numeric strings to numbers
echo json_encode(['price' => '150000', 'qty' => '3']);
// {"price":"150000","qty":"3"}
echo json_encode(['price' => '150000', 'qty' => '3'], JSON_NUMERIC_CHECK);
// {"price":150000,"qty":3}
// WARNING: this can change string IDs like "007" into 7
// JSON_PRESERVE_ZERO_FRACTION — keep .0 for floats that are whole numbers
echo json_encode(['value' => 1.0]);
// {"value":1} ← PHP 7 default: strips .0
echo json_encode(['value' => 1.0], JSON_PRESERVE_ZERO_FRACTION);
// {"value":1.0} ← more accurate for type-conscious APIs
json_encode() Error Handling
#
json_encode() fails silently if you don’t use JSON_THROW_ON_ERROR:
<?php
// ANTI-PATTERN: ignoring the possibility of failure
$json = json_encode($data);
echo $json; // could be false! — but no error shown
// Common failure sources:
// 1. Strings containing invalid UTF-8 encoding
$broken = "Text with broken \xFF bytes";
var_dump(json_encode($broken)); // bool(false)
echo json_last_error(); // JSON_ERROR_UTF8 (5)
echo json_last_error_msg(); // "Malformed UTF-8 characters, possibly incorrectly encoded"
// 2. Invalid float values (INF, NAN)
var_dump(json_encode(INF)); // bool(false)
var_dump(json_encode(NAN)); // bool(false)
// 3. Recursion depth exceeding the limit (default 512)
// Very deep arrays or objects (> 512 levels)
// CORRECT: use JSON_THROW_ON_ERROR
function encodeSafe(mixed $data, int $flags = 0): string
{
try {
return json_encode($data, $flags | JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new \RuntimeException(
"JSON encode failed: " . $e->getMessage(),
previous: $e
);
}
}
// Or catch it directly
try {
$json = json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
} catch (\JsonException $e) {
// Handle the error
error_log("JSON encode failed: " . $e->getMessage());
throw $e;
}
// Sanitize problematic strings before encoding
function sanitizeUtf8(string $string): string
{
// Remove or replace invalid UTF-8 characters
return mb_convert_encoding($string, 'UTF-8', 'UTF-8');
}
json_decode() — JSON to PHP
#
<?php
$json = '{"name":"Budi","age":28,"active":true,"score":null}';
// Default: decode to a stdClass object
$obj = json_decode($json);
echo $obj->name; // Budi
echo $obj->age; // 28
var_dump($obj->active); // bool(true)
var_dump($obj->score); // NULL
// Second argument true: decode to an associative array
$arr = json_decode($json, associative: true);
echo $arr['name']; // Budi
echo $arr['age']; // 28
// Choosing between object and array
// → Use an array if you only need data access
// → Use an object if you need type hints or methods
Decoding to Object vs Array — When to Use Which #
<?php
// Decode to array — more common and direct
$data = json_decode($json, associative: true);
if (!is_array($data)) {
throw new \JsonException("Invalid JSON or not an object/array");
}
echo $data['user']['name'];
foreach ($data['items'] as $item) {
echo $item['name'] . ": Rp" . $item['price'] . "\n";
}
// Decode to stdClass — useful when you want object-style access
$obj = json_decode($json);
echo $obj->user->name;
// WARNING: accessing a non-existent property doesn't raise an error in PHP 8+
// it just produces null — same as an array with a missing key
echo $obj->nonExistentProperty ?? 'default'; // null, not an error
// Decode to a specific class — manual, no native way
// Must map manually
class UserDTO
{
public function __construct(
public readonly int $id,
public readonly string $name,
public readonly string $email,
) {}
public static function fromJson(string $json): static
{
$data = json_decode($json, associative: true, flags: JSON_THROW_ON_ERROR);
return new static(
id: $data['id'],
name: $data['name'],
email: $data['email'],
);
}
public static function fromArray(array $data): static
{
return new static(
id: (int) ($data['id'] ?? throw new \InvalidArgumentException("id is required")),
name: (string) ($data['name'] ?? throw new \InvalidArgumentException("name is required")),
email: (string) ($data['email'] ?? throw new \InvalidArgumentException("email is required")),
);
}
}
$user = UserDTO::fromJson('{"id":1,"name":"Budi","email":"[email protected]"}');
echo $user->name; // Budi
json_decode() Error Handling
#
<?php
// ANTI-PATTERN: not validating the decode result
$data = json_decode($input, associative: true);
echo $data['name']; // fatal error if $data is null (invalid JSON)
// CORRECT: use JSON_THROW_ON_ERROR and validate the type
function decodeSafe(string $json): array
{
try {
$data = json_decode($json, associative: true, flags: JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new \InvalidArgumentException("Invalid JSON: " . $e->getMessage(), previous: $e);
}
if (!is_array($data)) {
throw new \InvalidArgumentException("JSON must be an object or array, not: " . gettype($data));
}
return $data;
}
// Validate the structure of JSON received from an external API
function validateStructure(array $data, array $requiredFields): void
{
foreach ($requiredFields as $field) {
if (!array_key_exists($field, $data)) {
throw new \RuntimeException("Field '$field' is missing from the response");
}
}
}
$response = decodeSafe($jsonFromApi);
validateStructure($response, ['id', 'status', 'data']);
The JsonSerializable Interface
#
To control how an object is encoded to JSON, implement the JsonSerializable interface:
<?php
class Order implements \JsonSerializable
{
public function __construct(
private int $id,
private string $status,
private float $total,
private array $items,
private \DateTimeImmutable $created,
private ?string $note = null,
) {}
// This method is called automatically by json_encode()
public function jsonSerialize(): mixed
{
return [
'id' => $this->id,
'status' => $this->status,
'total' => $this->total,
'items' => $this->items,
'created' => $this->created->format(\DateTimeInterface::ISO8601),
// Hide internal notes when null
...($this->note !== null ? ['note' => $this->note] : []),
];
}
// Getters for internal access
public function getId(): int { return $this->id; }
public function getTotal(): float { return $this->total; }
}
$order = new Order(
id: 42,
status: 'completed',
total: 150_000,
items: [['name' => 'Laptop', 'qty' => 1]],
created: new \DateTimeImmutable('2024-03-15 14:00:00'),
);
echo json_encode($order, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
/*
{
"id": 42,
"status": "completed",
"total": 150000,
"items": [{"name":"Laptop","qty":1}],
"created": "2024-03-15T14:00:00+0000"
}
*/
// Note: even if there are sensitive properties like $password, they won't appear
// in the JSON because jsonSerialize() controls what gets exposed
JsonSerializable Hierarchies #
<?php
// Nested objects, each implementing JsonSerializable
class Money implements \JsonSerializable
{
public function __construct(
private int $amount, // in cents
private string $currency, // 'IDR', 'USD', etc.
) {}
public function jsonSerialize(): mixed
{
return [
'amount' => $this->amount,
'currency' => $this->currency,
'formatted' => $this->format(),
];
}
public function format(): string
{
return match($this->currency) {
'IDR' => 'Rp ' . number_format($this->amount / 100, 0, ',', '.'),
'USD' => '$' . number_format($this->amount / 100, 2),
default => $this->currency . ' ' . ($this->amount / 100),
};
}
}
class Product implements \JsonSerializable
{
public function __construct(
private int $id,
private string $name,
private Money $price, // contains another JsonSerializable
) {}
public function jsonSerialize(): mixed
{
return [
'id' => $this->id,
'name' => $this->name,
'price' => $this->price, // json_encode automatically calls jsonSerialize() on Money
];
}
}
$product = new Product(1, 'Laptop', new Money(15_000_000_00, 'IDR')); // 15 million in cents
echo json_encode($product, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
/*
{
"id": 1,
"name": "Laptop",
"price": {
"amount": 1500000000,
"currency": "IDR",
"formatted": "Rp 15.000.000"
}
}
*/
Consistent REST API Response Patterns #
One of the most important uses of JSON in PHP is building REST APIs. Consistent response formatting is critical for API consumers:
<?php
class ApiResponse
{
private function __construct(
private readonly bool $success,
private readonly mixed $data,
private readonly ?string $message = null,
private readonly array $errors = [],
private readonly array $meta = [],
) {}
public static function ok(mixed $data, string $message = null, array $meta = []): static
{
return new static(true, $data, $message, [], $meta);
}
public static function fail(string $message, array $errors = [], int $code = 400): static
{
http_response_code($code);
return new static(false, null, $message, $errors);
}
public static function notFound(string $resource): static
{
return static::fail("$resource not found", code: 404);
}
public static function forbidden(): static
{
return static::fail("Access not allowed", code: 403);
}
public function send(): void
{
header('Content-Type: application/json; charset=utf-8');
$payload = ['success' => $this->success];
if ($this->message !== null) {
$payload['message'] = $this->message;
}
if ($this->success) {
$payload['data'] = $this->data;
} else {
$payload['errors'] = $this->errors;
}
if (!empty($this->meta)) {
$payload['meta'] = $this->meta;
}
echo json_encode(
$payload,
JSON_THROW_ON_ERROR
| JSON_UNESCAPED_UNICODE
| JSON_UNESCAPED_SLASHES
);
}
}
// Usage in a controller
function getUser(int $id): void
{
$user = findUser($id);
if ($user === null) {
ApiResponse::notFound('User')->send();
return;
}
ApiResponse::ok($user, meta: [
'cached_at' => date(\DateTimeInterface::ISO8601),
])->send();
}
function createUser(array $input): void
{
$errors = validateInput($input);
if (!empty($errors)) {
ApiResponse::fail("Validation failed", $errors, 422)->send();
return;
}
$user = saveUser($input);
http_response_code(201);
ApiResponse::ok($user, "User created successfully")->send();
}
Streaming JSON for Large Datasets #
Loading an entire large dataset into memory before encoding can cause out-of-memory errors. For these cases, streaming JSON is more appropriate:
<?php
// ANTI-PATTERN: encoding everything at once for large datasets
$allProducts = $db->query("SELECT * FROM products")->fetchAll(); // 100,000 rows!
echo json_encode($allProducts); // out of memory!
// CORRECT: stream JSON manually
function streamJsonArray(iterable $items, callable $transform = null): void
{
header('Content-Type: application/json; charset=utf-8');
echo '[';
$first = true;
foreach ($items as $item) {
if (!$first) {
echo ',';
}
$first = false;
$value = $transform !== null ? $transform($item) : $item;
echo json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
// Flush the output buffer periodically so it doesn't pile up
if (ob_get_level() > 0) {
ob_flush();
}
flush();
}
echo ']';
}
// Use with a cursor/generator from the database
$stmt = $pdo->query("SELECT id, name, price FROM products ORDER BY id");
$stmt->setFetchMode(\PDO::FETCH_ASSOC);
streamJsonArray($stmt, function(array $row): array {
return [
'id' => (int) $row['id'],
'name' => $row['name'],
'price' => (float) $row['price'],
];
});
// Only one row in memory at a time — no matter how many million rows
JSON Schema Validation #
For validating JSON structures received from external APIs or user input:
<?php
// Manual validation — for simple schemas
function validateOrderJson(array $data): array
{
$errors = [];
// Check required fields
foreach (['user_id', 'items', 'address'] as $field) {
if (!array_key_exists($field, $data)) {
$errors[$field] = "Field '$field' is required";
}
}
if (isset($data['items'])) {
if (!is_array($data['items']) || empty($data['items'])) {
$errors['items'] = "Items must be a non-empty array";
} else {
foreach ($data['items'] as $i => $item) {
if (!isset($item['product_id']) || !is_int($item['product_id'])) {
$errors["items.$i.product_id"] = "product_id must be an integer";
}
if (!isset($item['qty']) || $item['qty'] < 1) {
$errors["items.$i.qty"] = "qty must be at least 1";
}
}
}
}
if (isset($data['user_id']) && !is_int($data['user_id'])) {
$errors['user_id'] = "user_id must be an integer";
}
return $errors;
}
// For complex schema validation, use a library:
// composer require justinrainbow/json-schema
use JsonSchema\Validator;
use JsonSchema\Constraints\Constraint;
$schema = json_decode(file_get_contents('schema/order.json'));
$data = json_decode($inputJson);
$validator = new Validator();
$validator->validate($data, $schema, Constraint::CHECK_MODE_APPLY_DEFAULTS);
if (!$validator->isValid()) {
$errors = [];
foreach ($validator->getErrors() as $error) {
$errors[$error['property']] = $error['message'];
}
// return validation errors
}
JSON Data Transformation #
<?php
// Normalize date formats in an API response
function normalizeDates(array $data): array
{
$dateFields = ['created_at', 'updated_at', 'deleted_at', 'birth_date'];
foreach ($dateFields as $field) {
if (isset($data[$field]) && is_string($data[$field])) {
try {
$dt = new \DateTimeImmutable($data[$field]);
$data[$field] = $dt->format('Y-m-d H:i:s');
} catch (\Exception $e) {
// keep the original value if it can't be parsed
}
}
}
return $data;
}
// Remove sensitive fields before encoding to JSON
function sanitizeForPublic(array $user): array
{
$privateFields = ['password', 'password_hash', 'token', 'secret', 'pin'];
return array_diff_key($user, array_flip($privateFields));
}
// Transform snake_case to camelCase keys for JavaScript APIs
function snakeToCamelKeys(array $data): array
{
$result = [];
foreach ($data as $key => $value) {
$camelKey = lcfirst(str_replace('_', '', ucwords($key, '_')));
$result[$camelKey] = is_array($value) ? snakeToCamelKeys($value) : $value;
}
return $result;
}
$dbData = ['user_id' => 1, 'full_name' => 'Budi', 'created_at' => '2024-01-01'];
$apiData = snakeToCamelKeys($dbData);
echo json_encode($apiData);
// {"userId":1,"fullName":"Budi","createdAt":"2024-01-01"}
Common JSON Anti-Patterns #
<?php
// ✗ Anti-pattern 1: not handling encode errors
$json = json_encode($data); // returns false on failure!
echo $json; // outputs "false" to the client — invalid JSON
// ✓ Always use JSON_THROW_ON_ERROR
$json = json_encode($data, JSON_THROW_ON_ERROR);
// ✗ Anti-pattern 2: decode then re-encode for "validation"
$valid = json_encode(json_decode($input)); // loses data types (int → float, etc.)
// ✓ Validate explicitly
try {
$data = json_decode($input, true, flags: JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new \InvalidArgumentException("Input is not valid JSON");
}
// ✗ Anti-pattern 3: storing JSON directly in the database then decoding repeatedly
// Every access requires a decode — expensive for frequently accessed data
$db->query("UPDATE users SET config = ?", [json_encode($config)]);
$row = $db->query("SELECT config FROM users WHERE id = 1")->fetch();
$config = json_decode($row['config'], true); // decode on every access
// ✓ Decode once, store in a variable/cache
$config = json_decode($row['config'], true);
// Access $config['theme'] instead of json_decode(..., true)['theme']
// ✗ Anti-pattern 4: using json_decode to check whether a string is valid JSON
if (json_decode($input) !== null) { /* valid? */ }
// json_decode("null") also returns null — false negative!
// ✓ The correct way
function isValidJson(string $string): bool
{
try {
json_decode($string, flags: JSON_THROW_ON_ERROR);
return true;
} catch (\JsonException) {
return false;
}
}
// ✗ Anti-pattern 5: encoding objects with sensitive properties without filtering
class User {
public int $id;
public string $name;
public string $passwordHash; // DANGEROUS if encoded!
}
$user = new User();
echo json_encode($user); // {"id":1,"name":"Budi","passwordHash":"$2y$..."}
// ✓ Implement JsonSerializable for explicit control
class User implements \JsonSerializable {
public function jsonSerialize(): mixed {
return ['id' => $this->id, 'name' => $this->name];
// passwordHash is not included
}
}
Summary #
- Always use
JSON_THROW_ON_ERROR— without it,json_encode()andjson_decode()silently returnfalse/nullon failure.JSON_THROW_ON_ERRORturns those into catchable\JsonExceptions.- Important
json_encode()flags:JSON_UNESCAPED_UNICODE(Unicode characters aren’t escaped),JSON_UNESCAPED_SLASHES(URL-friendly),JSON_PRETTY_PRINT(for debugging),JSON_PRESERVE_ZERO_FRACTION(keeps.0on floats).json_decode($json, true)produces an associative array; without the second argument it producesstdClass. Use arrays for most cases — easier to manipulate.- The
JsonSerializableinterface gives full control over an object’s JSON representation — use it to hide sensitive fields, change formats, or compress data before encoding.- Validate after decoding — don’t assume external JSON is always valid or has the expected structure. Check
is_array(), key existence, and value types.- Stream JSON for large datasets — emit
[, encode one item at a time with,separators, close with]. AvoidfetchAll()+json_encode()for hundreds of thousands of rows.- Sanitize before encoding — remove sensitive fields (
password,token) before JSON is sent to the client.array_diff_key()is useful for this.json_encode()automatically callsjsonSerialize()on objects implementingJsonSerializable, including nested objects — no manual encoding needed.