JSON in PHP: Encoding, Decoding, and API Serialization

Convert PHP data to and from JSON with json_encode/json_decode, choose between associative arrays and stdClass objects when decoding, use JSON_THROW_ON_ERROR (and try/catch with JsonException) instead of silently ignoring failures, control Unicode escaping with JSON_UNESCAPED_UNICODE, understand numeric precision pitfalls, and structure clean API response serialization.

JSON is the de facto data-interchange format for web APIs, and PHP's json_encode()/json_decode() pair is how almost every PHP application talks to a browser, a mobile client, or another service. The functions look trivially simple — pass a value in, get a string or value out — but three things trip up real projects constantly: silently swallowed decode errors on malformed input, the associative-array-vs-object ambiguity when decoding, and Unicode/number formatting choices that change what the client actually receives. Getting these right is the difference between an API that fails loudly and predictably and one that produces confusing null values and mojibake in production.

json_encode and json_decode basics

json_encode(mixed $value, int $flags = 0, int $depth = 512): string|false converts a PHP value into its JSON string representation. PHP arrays with sequential integer keys starting at 0 become JSON arrays ([1,2,3]); arrays with string keys, or non-sequential integer keys, become JSON objects ({"a":1}). Scalars (strings, ints, floats, bools, null) map to their obvious JSON counterparts, and PHP objects are encoded using their public properties by default, unless the class implements JsonSerializable (covered below).

json_decode(string $json, ?bool $associative = null, int $depth = 512, int $flags = 0): mixed does the reverse — it parses a JSON string back into a PHP value. The second parameter controls a decision that matters a lot in practice: passing true decodes JSON objects into PHP associative arrays; passing false (or omitting it, since the default is null which behaves as false) decodes them into instances of stdClass, where each JSON key becomes a public property accessed with ->.

Associative arrays vs stdClass

Decoding to an associative array (json_decode($json, true)) is usually the more convenient and PHP-idiomatic choice: you get $data['user']['name'], arrays work naturally with functions like array_map, array_filter, and count, and there is no ambiguity about whether a nested value is an array or an object. Decoding to stdClass (json_decode($json)) gives you $data->user->name property access instead, which some developers find more readable for representing a fixed-shape record, but stdClass objects don't support array functions directly and require care when a key isn't a valid PHP property name (e.g. a JSON key containing a hyphen requires $data->{'weird-key'}).

There is no universally 'correct' choice — many teams standardize on associative arrays for API request/response bodies specifically because they compose well with the rest of PHP's array-oriented standard library, while others prefer stdClass or, better, decode into typed DTOs (data transfer objects) for stronger guarantees about shape. Whichever you pick, be consistent within a codebase so callers aren't guessing which access syntax a given decoded value needs.

• json_decode($json, true) -> nested associative arrays, use ['key'] access • json_decode($json) or json_decode($json, false) -> nested stdClass objects, use ->key access • Associative arrays compose better with array_map/array_filter/array_column and JSON-shaped data in general • stdClass access breaks for JSON keys that aren't valid property identifiers without brace syntax: $obj->{'not-valid'}

JSON_THROW_ON_ERROR and error handling

By default, both json_encode() and json_decode() do NOT throw on failure — they return false or null respectively, and you're expected to separately call json_last_error() to find out what went wrong. This default is a classic footgun: json_decode('not valid json') silently returns null, which is indistinguishable from successfully decoding the JSON literal null, so code that doesn't check json_last_error() can proceed with a bogus null value and fail confusingly much later.

Since PHP 7.3, passing the JSON_THROW_ON_ERROR flag to either function makes it throw a JsonException on failure instead of returning false/null, which is almost always what you want in application code: it turns a silent, easy-to-miss failure into a loud, catchable one at the exact point where the bad data entered your program. The idiomatic modern pattern wraps encode/decode calls in try/catch and always passes the flag explicitly rather than relying on manual json_last_error() checks scattered through the codebase.

You can also set the json.exceptions php.ini directive to make JSON_THROW_ON_ERROR the implicit default for every call, but relying on ini configuration for correctness is fragile across environments — explicitly passing the flag at each call site (or through a small wrapper function) is the more portable, self-documenting habit.

Unicode escaping and JSON_UNESCAPED_UNICODE

By default, json_encode() escapes all non-ASCII characters using \uXXXX sequences — encoding the string "café" produces "caf\u00e9" rather than the literal UTF-8 bytes. This is valid JSON and every compliant parser handles it correctly, but it makes payloads harder to read in logs/debugging tools and larger in byte size for text-heavy non-English content. Passing the JSON_UNESCAPED_UNICODE flag disables this escaping and emits the actual UTF-8 characters directly: json_encode("café", JSON_UNESCAPED_UNICODE) produces "café".

Similarly, json_encode() by default escapes forward slashes as \/ (so a URL like https://example.com becomes https:\/\/example.com in the output) purely as a historical safety measure for embedding JSON inside HTML <script> tags; the JSON_UNESCAPED_SLASHES flag disables that. Combining both — JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES — is standard practice for clean, human-readable API responses. Note that json_decode() correctly parses both escaped and unescaped forms regardless of these flags, since they only affect encoding output, never decoding input.

Numeric precision and large numbers

JSON's spec doesn't distinguish integer and float types the way PHP does — it just has 'number'. This creates two real gotchas. First, PHP floats are IEEE-754 double-precision, so encoding a float can lose trailing precision or use scientific notation unexpectedly; the JSON_PRESERVE_ZERO_FRACTION flag keeps a float like 10.0 encoded as 10.0 rather than being silently collapsed to the integer-looking 10, which matters when a strictly-typed client distinguishes int and float fields.

Second, and more dangerous: very large integers (bigger than PHP_INT_MAX, or bigger than JavaScript's safe integer range of 2^53-1) can silently lose precision when decoded as PHP floats, or get rounded by a JavaScript client that parses the same JSON with its own float-based number type. The JSON_BIGINT_AS_STRING flag on json_decode() keeps oversized integers as PHP strings instead of lossy floats, which is the safe choice whenever a payload may carry IDs or values exceeding the 53-bit safe integer range (for example, some database-generated 64-bit snowflake IDs) — many APIs deliberately serialize such IDs as JSON strings in the first place to sidestep the issue entirely on the wire.

• JSON_PRESERVE_ZERO_FRACTION: keep 10.0 as "10.0" instead of "10" when encoding floats • JSON_BIGINT_AS_STRING: decode oversized integers as PHP strings instead of lossy floats • Best practice for very large IDs (64-bit snowflake/database IDs): serialize them as JSON strings from the source, avoiding ambiguity for every downstream consumer

API serialization: DTOs and JsonSerializable

Encoding raw associative arrays works for small scripts, but real APIs benefit from a clear boundary between internal domain objects and the exact shape sent over the wire. A common pattern is a DTO (data transfer object) — a small, purpose-built class holding only the fields the response should expose — constructed from richer internal data (an ORM entity, a database row) and then encoded directly with json_encode(). Because json_encode() only serializes public properties by default, a DTO with public readonly properties naturally produces exactly the intended JSON shape without extra glue code, and any internal-only fields (password hashes, internal flags) simply never leak because they're never on the DTO in the first place.

For cases needing custom control over the exact JSON shape — renaming fields, omitting nulls, formatting a DateTime as an ISO 8601 string — a class can implement the JsonSerializable interface's single method, jsonSerialize(): mixed, which json_encode() calls automatically whenever it encounters an instance of that class, using its return value (typically an array) as the encoded representation instead of the object's raw public properties. This gives full control while still letting nested objects (an order containing an array of line-item DTOs) compose naturally — json_encode() recurses into JsonSerializable automatically at every level.

json_encode / json_decode signatures and common flags

Always pass JSON_THROW_ON_ERROR explicitly rather than relying on the json.exceptions ini setting, so the code's error-handling intent is visible at the call site. JSON_PRETTY_PRINT is great for debugging/logging but adds bytes and latency — skip it in production API responses meant for machine consumption.

// Signatures
json_encode(mixed $value, int $flags = 0, int $depth = 512): string|false
json_decode(string $json, ?bool $associative = null, int $depth = 512, int $flags = 0): mixed

// Recommended modern pattern
try {
    $json = json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    $decoded = json_decode($json, associative: true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
    // handle malformed/unencodable data explicitly, loudly
}

// Common flags
JSON_THROW_ON_ERROR        // throw JsonException instead of returning false/null
JSON_UNESCAPED_UNICODE     // emit real UTF-8 chars instead of \uXXXX escapes
JSON_UNESCAPED_SLASHES     // don't escape / as \/
JSON_PRETTY_PRINT          // human-readable indentation (debugging, not production APIs)
JSON_PRESERVE_ZERO_FRACTION// keep 10.0 as "10.0" not "10"
JSON_BIGINT_AS_STRING      // (decode only) oversized ints as PHP strings, not lossy floats

Code Examples

A plain associative array with string keys encodes to a JSON object. Decoding back with associative: true and JSON_THROW_ON_ERROR round-trips it to an array accessed with bracket syntax, and any malformed JSON here would throw rather than silently return null. Expected result: {"id":42,"name":"Ada Lovelace","active":true} Ada Lovelace

Wrapping json_decode() in a small helper with JSON_THROW_ON_ERROR converts a low-level JsonException into a domain-appropriate exception at the API boundary, giving callers a single, predictable failure mode. JSON_UNESCAPED_UNICODE controls whether 'ü' is emitted as the literal UTF-8 character or as a \u00fc escape — both are valid JSON, but unescaped is more readable. Expected result: array(2) { ["city"]=> string(7) "Zürich" ["temp"]=> float(21.5) } {"city":"Zürich"} Caught: Malformed JSON payload: Syntax error

JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE The DTO implements JsonSerializable, so json_encode() calls jsonSerialize() automatically instead of dumping public properties (there are none exposed here — everything is private/readonly). This gives full, explicit control over the wire format: field names, date formatting, and exclusion of any internal-only data, which is exactly the boundary a production API serialization layer needs.

<?php

$user = [
    'id' => 42,
    'name' => 'Ada Lovelace',
    'active' => true,
];

$json = json_encode($user, JSON_THROW_ON_ERROR);
echo $json;
// {"id":42,"name":"Ada Lovelace","active":true}

$decoded = json_decode($json, associative: true, flags: JSON_THROW_ON_ERROR);
echo $decoded['name']; // Ada Lovelace
<?php

function decodeApiPayload(string $raw): array
{
    try {
        return json_decode($raw, associative: true, flags: JSON_THROW_ON_ERROR);
    } catch (JsonException $e) {
        throw new InvalidArgumentException(
            'Malformed JSON payload: ' . $e->getMessage(),
            previous: $e
        );
    }
}

$good = decodeApiPayload('{"city": "Zürich", "temp": 21.5}');
var_dump($good);

// Unicode round trip
$out = json_encode(['city' => 'Zürich'], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
echo $out; // {"city":"Z\u00fcrich"} WITHOUT the flag, {"city":"Zürich"} WITH it

try {
    decodeApiPayload('{not valid json,,,}');
} catch (InvalidArgumentException $e) {
    echo 'Caught: ' . $e->getMessage() . "\n";
}
<?php

final class UserResponseDto implements JsonSerializable
{
    public function __construct(
        private readonly int $id,
        private readonly string $name,
        private readonly string $email,
        private readonly DateTimeImmutable $joinedAt,
    ) {}

    public function jsonSerialize(): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'email' => $this->email,
            // formatted explicitly -- never leak the raw internal representation
            'joined_at' => $this->joinedAt->format(DATE_ATOM),
        ];
    }
}

// Imagine this came from a database row / ORM entity with extra internal fields
// (password_hash, internal_flags, etc.) that must NEVER reach the client.
$dto = new UserResponseDto(
    id: 42,
    name: 'Ada Lovelace',
    email: 'ada@example.com',
    joinedAt: new DateTimeImmutable('2024-01-15T09:30:00+00:00'),
);

header('Content-Type: application/json');
echo json_encode($dto, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
// {"id":42,"name":"Ada Lovelace","email":"ada@example.com","joined_at":"2024-01-15T09:30:00+00:00"}

PHP <-> JSON round trip

Both directions can fail on malformed/unencodable data -- JSON_THROW_ON_ERROR is what turns that failure into a catchable exception instead of a silent false/null.

PHP value                 json_encode()                JSON string
  ['a'=>1, 'b'=>[2,3]]  ------------------------>  {"a":1,"b":[2,3]}
         ^                                                  |
         |                                                  |
         +------------  json_decode($s, true)  <------------+
           (associative array)      OR
           json_decode($s)  ---->  stdClass object (->prop access)

  Flags at each step:
    encode: JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
    decode: JSON_THROW_ON_ERROR | JSON_BIGINT_AS_STRING (if huge integers expected)

Important Rules

Always pass JSON_THROW_ON_ERROR to both json_encode() and json_decode() in application code -- never rely on a bare false/null return plus a forgotten json_last_error() check.

Decide once, project-wide, whether decoded JSON becomes associative arrays or stdClass objects, and be consistent -- mixing both access styles across a codebase is a constant source of bugs.

Use JSON_UNESCAPED_UNICODE (and usually JSON_UNESCAPED_SLASHES) for API responses meant to be read by humans or non-JS clients; the default \uXXXX escaping is correct but harder to read.

Never trust that a value survived JSON round-tripping with full numeric precision -- treat huge integers and financial values with the same care as any cross-language boundary (strings or dedicated decimal types).

For API responses, prefer explicit DTOs (optionally implementing JsonSerializable) over encoding raw internal objects/arrays, so internal-only fields can never accidentally leak onto the wire.

Where this shows up

Every REST/JSON API written in PHP -- a Laravel controller returning response()->json(), a Symfony controller returning a JsonResponse, or a plain PHP script echoing json_encode() output -- goes through exactly this encode/decode boundary. Frameworks typically wrap these calls with their own error handling and DTO/resource layers internally, but they're built on the same json_encode()/json_decode() primitives and the same JSON_THROW_ON_ERROR, Unicode, and numeric-precision concerns discussed here; understanding the raw functions makes it much easier to reason about what a framework's serializer is actually doing under the hood.

<?php $data = json_decode($_POST['payload']); // no error check at all echo $data->name; // Warning: Attempt to read property "name" on null

<?php try { $data = json_decode($_POST['payload'], associative: true, flags: JSON_THROW_ON_ERROR); } catch (JsonException $e) { http_response_code(400); exit('Invalid JSON payload'); } echo $data['name'];

Without JSON_THROW_ON_ERROR, malformed input silently decodes to null, and every following access to it produces confusing 'null' warnings deep in the code with no clear indication the root cause was bad input. Throwing and handling the exception right at the decode call turns an obscure downstream warning into an immediate, correct 400 response.

[multiple-choice] Without JSON_THROW_ON_ERROR, what does json_decode('not valid json') return, and why is that dangerous? A. It throws a fatal error immediately, so the danger is a crashed process B. It returns false, which is easy to check for with a strict comparison C. It returns null, which is indistinguishable from successfully decoding the JSON literal null unless you separately check json_last_error() D. It returns an empty array, silently discarding the malformed content

C. It returns null, which is indistinguishable from successfully decoding the JSON literal null unless you separately check json_last_error() — json_decode() returns null both when parsing fails AND when the input was the valid JSON value null, so code that doesn't check json_last_error() can't tell the two apart -- this ambiguity is exactly what JSON_THROW_ON_ERROR eliminates by throwing a JsonException on real failures instead.

[multiple-choice] What does the JSON_UNESCAPED_UNICODE flag change about json_encode()'s output? A. It makes json_encode() reject any non-ASCII input with an error B. It emits actual UTF-8 characters directly instead of escaping them as \uXXXX sequences C. It converts all strings to ASCII by stripping non-ASCII characters D. It has no effect on encoding, only on decoding

B. It emits actual UTF-8 characters directly instead of escaping them as \uXXXX sequences — By default json_encode() escapes every non-ASCII character as a \uXXXX sequence, which is valid but harder to read; JSON_UNESCAPED_UNICODE outputs the literal UTF-8 bytes instead, e.g. 'café' instead of 'caf\u00e9', while remaining equally valid JSON either way.

[multiple-choice] When decoding JSON, what is the practical difference between json_decode($json, true) and json_decode($json)? A. There is no difference, the second argument is purely cosmetic B. true decodes JSON objects into PHP associative arrays (bracket access); omitting it (or false) decodes them into stdClass objects (arrow access) C. true makes decoding faster but loses nested structure D. true is required for JSON containing numbers, false is required for JSON containing strings

B. true decodes JSON objects into PHP associative arrays (bracket access); omitting it (or false) decodes them into stdClass objects (arrow access) — The second parameter controls whether JSON objects become PHP associative arrays ($data['key']) or stdClass instances ($data->key). Neither is universally correct, but the choice changes the access syntax and which PHP array functions are directly usable on the result.

Write a function safeJsonDecode(string $json): array that decodes a JSON string into an associative array, using JSON_THROW_ON_ERROR, and catches JsonException to instead throw a RuntimeException with a friendlier message ('Could not parse JSON: <original message>'). Then write a small UserDto class implementing JsonSerializable with private readonly id, name, and email properties, whose jsonSerialize() returns those three fields as an array, and encode an instance of it with JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE.

function safeJsonDecode(string $json): array { try { return json_decode($json, associative: true, flags: JSON_THROW_ON_ERROR); } catch (JsonException $e) { throw new RuntimeException('Could not parse JSON: ' . $e->getMessage(), previous: $e); } } final class UserDto implements JsonSerializable { public function __construct( private readonly int $id, private readonly string $name, private readonly string $email, ) {} public function jsonSerialize(): array { return ['id' => $this->id, 'name' => $this->name, 'email' => $this->email]; } } echo json_encode(new UserDto(1, 'Ada', 'ada@example.com'), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);

The try/catch goes inside safeJsonDecode around the json_decode call; the DTO needs implements JsonSerializable and a public function jsonSerialize(): array method returning the desired shape.