PHP JSON: json_encode, json_decode, Flags & Laravel JSON
Last updated:
PHP encodes arrays and objects to JSON with json_encode() and parses JSON strings with json_decode() — json_decode($json, true) returns a PHP associative array (second parameter true), while json_decode($json) returns a stdClass object, a common source of bugs when the second argument is forgotten. json_encode() returns false on error (circular references, invalid UTF-8 bytes) without throwing an exception — always check json_last_error() === JSON_ERROR_NONE or use json_encode($data) ?: throw new JsonException() in PHP 8.0+ with the JSON_THROW_ON_ERROR flag. This guide covers json_encode() and json_decode() with all flags, error handling with JSON_THROW_ON_ERROR, encoding class properties, streaming JSON responses, Guzzle HTTP client JSON, and returning JSON responses in Laravel and Symfony.
json_encode() Flags and Configuration
json_encode() accepts a bitmask of flags as its second argument, controlling output format, character encoding, and error behavior. Combine multiple flags with the bitwise OR operator (|). The most useful flags are JSON_PRETTY_PRINT for human-readable output, JSON_UNESCAPED_UNICODE for non-ASCII characters, JSON_UNESCAPED_SLASHES for URLs, JSON_FORCE_OBJECT to encode sequential arrays as JSON objects, and JSON_THROW_ON_ERROR for exception-based error handling.
<?php
$data = [
'name' => 'Alice Müller',
'website' => 'https://example.com/users/alice',
'tags' => ['admin', 'editor'],
'score' => 42,
];
// Default — non-ASCII escaped, slashes escaped
echo json_encode($data);
// {"name":"Alice M\u00fcller","website":"https:\/\/example.com\/users\/alice",...}
// JSON_PRETTY_PRINT — human-readable with 4-space indent
echo json_encode($data, JSON_PRETTY_PRINT);
// {
// "name": "Alice M\u00fcller",
// "website": "https:\/\/example.com\/users\/alice",
// "tags": ["admin","editor"],
// "score": 42
// }
// JSON_UNESCAPED_UNICODE — preserve non-ASCII characters as-is
echo json_encode($data, JSON_UNESCAPED_UNICODE);
// {"name":"Alice Müller",...}
// JSON_UNESCAPED_SLASHES — preserve forward slashes in URLs
echo json_encode($data, JSON_UNESCAPED_SLASHES);
// {"website":"https://example.com/users/alice",...}
// Combine flags for API responses
$flags = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR;
echo json_encode($data, $flags);
// {"name":"Alice Müller","website":"https://example.com/users/alice",...}
// JSON_PRETTY_PRINT + all useful flags for debugging
$debugFlags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
echo json_encode($data, $debugFlags);
// JSON_FORCE_OBJECT — encode sequential array as JSON object (keys become "0", "1", ...)
$list = ['apple', 'banana', 'cherry'];
echo json_encode($list); // ["apple","banana","cherry"]
echo json_encode($list, JSON_FORCE_OBJECT); // {"0":"apple","1":"banana","2":"cherry"}
// JSON_NUMERIC_CHECK — encode numeric strings as numbers (use with caution)
$prices = ['price' => '19.99', 'qty' => '5'];
echo json_encode($prices); // {"price":"19.99","qty":"5"}
echo json_encode($prices, JSON_NUMERIC_CHECK); // {"price":19.99,"qty":5}
// Third parameter: depth limit (default 512)
// Reduce depth for untrusted input to limit recursion attack surface
echo json_encode($data, JSON_THROW_ON_ERROR, 32);
// Key type rule: sequential integer keys → JSON array, string keys → JSON object
$arr = [0 => 'a', 1 => 'b', 2 => 'c']; // ["a","b","c"] — array
$obj = ['x' => 'a', 'y' => 'b']; // {"x":"a","y":"b"} — object
$mixed = [0 => 'a', 'x' => 'b']; // {"0":"a","x":"b"} — object (mixed!)
echo json_encode($arr);
echo json_encode($obj);
echo json_encode($mixed); // mixed keys always produce JSON objectThe JSON_NUMERIC_CHECK flag converts numeric strings to numbers — useful when you receive all values as strings from a form or CSV, but dangerous if you have intentionally string-typed data like phone numbers or ZIP codes starting with zeros ("07700" becomes 7700). Always prefer explicitly casting values ((int)$value, (float)$value) over JSON_NUMERIC_CHECK for production code. The depth parameter (third argument to json_encode()) defaults to 512 and controls the maximum nesting depth — reduce it to 10-32 for user-supplied data to prevent deep-recursion denial-of-service attacks.
json_decode() Return Types and Common Bugs
json_decode() has two fundamentally different return modes controlled by the second parameter: false (default, omitted) returns stdClass objects for JSON objects, and true returns PHP associative arrays. The choice determines how you access decoded data throughout your entire codebase. A third critical parameter is the depth limit (default 512), and a fourth is a flags bitmask for options like JSON_BIGINT_AS_STRING.
<?php
$json = '{"user":{"id":123,"name":"Alice","roles":["admin","editor"]}}';
// ── Return mode: stdClass (default, second param false/omitted) ──
$obj = json_decode($json);
echo $obj->user->name; // Alice
echo $obj->user->roles[0]; // admin
// Danger: passing $obj to array functions causes TypeError
// array_keys($obj->user); // TypeError: array_keys(): Argument #1 must be of type array
// ── Return mode: associative array (second param true) ───────────
$arr = json_decode($json, true);
echo $arr['user']['name']; // Alice
echo $arr['user']['roles'][0]; // admin
array_keys($arr['user']); // ['id', 'name', 'roles'] — works fine
// ── Large integer handling: JSON_BIGINT_AS_STRING ─────────────────
// JavaScript max safe integer: 9007199254740991 (2^53 - 1)
// PHP float cannot represent integers > 2^53 exactly
$bigJson = '{"id":9999999999999999999}';
$withFloat = json_decode($bigJson, true);
var_dump($withFloat['id']); // float(1.0E+19) — precision lost!
$withString = json_decode($bigJson, true, 512, JSON_BIGINT_AS_STRING);
var_dump($withString['id']); // string(19) "9999999999999999999" — preserved
// ── Depth limit: fourth argument ──────────────────────────────────
$deepJson = str_repeat('{"x":', 10) . 'null' . str_repeat('}', 10);
$result = json_decode($deepJson, true, 5); // Exceeds depth 5
var_dump($result); // NULL — and json_last_error() === JSON_ERROR_DEPTH
// ── Decoding JSON null is NOT an error ───────────────────────────
$nullResult = json_decode('null', true);
var_dump($nullResult); // NULL
var_dump(json_last_error() === JSON_ERROR_NONE); // true — null is valid JSON
// ── Invalid JSON returns null ──────────────────────────────────────
$invalid = json_decode('{bad json}', true);
var_dump($invalid); // NULL
var_dump(json_last_error()); // 4 (JSON_ERROR_SYNTAX)
// ── Distinguishing null JSON from decode error ────────────────────
function safeJsonDecode(string $json, bool $assoc = true): mixed
{
$result = json_decode($json, $assoc, 512, JSON_THROW_ON_ERROR);
// JSON_THROW_ON_ERROR throws JsonException on error, never returns null for bad input
// A null return here means the JSON string was the literal "null" — valid
return $result;
}
// ── Third-party class deserialization (symfony/serializer) ────────
// For deserializing JSON into typed PHP objects, use a serializer:
// composer require symfony/serializer symfony/property-access
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
$serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]);
$user = $serializer->deserialize($json, User::class, 'json');
// $user is now a typed User object with $user->getName() etc.Always use JSON_BIGINT_AS_STRING when decoding JSON from JavaScript clients or APIs that use large integer IDs — Twitter/X snowflake IDs (18-19 digits) and database IDs from distributed systems commonly exceed PHP float precision. The 64-bit integer 9007199254740993 becomes 9007199254740992 (one less) when decoded as a PHP float — a silent data corruption bug that causes lookup failures. For typed class deserialization (JSON to PHP objects with type hints), use Symfony Serializer or jms/serializer rather than hand-mapping json_decode() results.
Error Handling: json_last_error() and JSON_THROW_ON_ERROR
PHP's JSON functions fail silently by default — json_encode() returns false and json_decode() returns null without throwing exceptions. JSON_THROW_ON_ERROR (added in PHP 7.3) fixes this by making both functions throw JsonException on failure. Use it in all production code; use json_last_error()only when supporting PHP < 7.3 or in code paths where you inspect the error type.
<?php
// ── PHP 7.3+: JSON_THROW_ON_ERROR (recommended) ───────────────────
try {
$encoded = json_encode($data, JSON_THROW_ON_ERROR);
$decoded = json_decode($jsonString, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
error_log('JSON error: ' . $e->getMessage() . ' (code ' . $e->getCode() . ')');
throw $e; // or return a 400 response
}
// ── json_last_error() constants ───────────────────────────────────
$result = json_decode($input, true);
switch (json_last_error()) {
case JSON_ERROR_NONE:
// Success — $result may still be null if input was "null"
break;
case JSON_ERROR_SYNTAX:
throw new \InvalidArgumentException('Malformed JSON: ' . json_last_error_msg());
case JSON_ERROR_UTF8:
// Invalid UTF-8 bytes in string values
// Fix: mb_convert_encoding($input, 'UTF-8', 'auto')
throw new \InvalidArgumentException('Invalid UTF-8 in JSON input');
case JSON_ERROR_DEPTH:
throw new \InvalidArgumentException('JSON nesting too deep');
case JSON_ERROR_RECURSION:
// Only from json_encode() — circular reference in input data
throw new \RuntimeException('Circular reference in data structure');
case JSON_ERROR_INF_OR_NAN:
// Only from json_encode() — INF or NAN float values are not valid JSON
throw new \RuntimeException('INF or NAN cannot be encoded as JSON');
default:
throw new \RuntimeException('JSON error: ' . json_last_error_msg());
}
// ── Pre-PHP 7.3 pattern: check return value ───────────────────────
$encoded = json_encode($data);
if ($encoded === false) {
throw new \RuntimeException('json_encode failed: ' . json_last_error_msg());
}
// ── Validate JSON before decoding ─────────────────────────────────
function isValidJson(string $string): bool
{
json_decode($string);
return json_last_error() === JSON_ERROR_NONE;
}
// PHP 8.3+: json_validate() — more efficient than decode-and-discard
// if (json_validate($string)) { ... }
// ── Fix common encoding issues before json_encode() ───────────────
function sanitizeForJson(array $data): array
{
array_walk_recursive($data, function (&$value) {
if (is_string($value)) {
// Ensure valid UTF-8 — replace invalid bytes
$value = mb_convert_encoding($value, 'UTF-8', 'UTF-8');
}
if (is_float($value) && (is_infinite($value) || is_nan($value))) {
$value = null; // Replace INF/NAN with null
}
});
return $data;
}
$safeData = sanitizeForJson($rawData);
$json = json_encode($safeData, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);PHP 8.3 added json_validate() — a function that validates a JSON string without decoding it, using less memory and CPU than json_decode() followed by a check. Use it to validate large JSON strings (config files, webhook payloads) before processing. The JSON_ERROR_UTF8 error is the most common encoding error in real-world PHP code: it occurs when strings contain bytes from ISO-8859-1, Windows-1252, or other single-byte encodings. Fix the encoding at the source — when reading from databases, specify the character set in the connection (SET NAMES utf8mb4 in MySQL); when reading files, use mb_convert_encoding() before encoding.
Encoding PHP Classes and Custom JsonSerializable
By default, json_encode() encodes only public properties of objects — private and protected properties are silently omitted. Implement the JsonSerializable interface to control exactly which properties and values appear in the JSON output. This is the correct way to encode objects with private state, transform property names, include computed values, or format complex types like DateTime.
<?php
// ── Default encoding — only public properties ─────────────────────
class User
{
public int $id;
public string $name;
private string $passwordHash; // omitted by json_encode()
public function __construct(int $id, string $name, string $hash)
{
$this->id = $id;
$this->name = $name;
$this->passwordHash = $hash;
}
}
$user = new User(1, 'Alice', '$2y$10$...');
echo json_encode($user);
// {"id":1,"name":"Alice"} — passwordHash is correctly omitted
// ── JsonSerializable — full control over JSON output ──────────────
class Product implements \JsonSerializable
{
public function __construct(
private int $id,
private string $name,
private float $priceInCents, // internal: cents
private \DateTime $createdAt,
private bool $active
) {}
public function jsonSerialize(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'price' => round($this->priceInCents / 100, 2), // transform: cents → dollars
'created_at' => $this->createdAt->format(\DateTimeInterface::ATOM), // ISO 8601
'active' => $this->active,
// Computed property not stored as a field
'price_display' => '$' . number_format($this->priceInCents / 100, 2),
];
}
}
$product = new Product(42, 'Widget', 1999, new \DateTime('2026-01-15'), true);
echo json_encode($product, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
// {
// "id": 42,
// "name": "Widget",
// "price": 19.99,
// "created_at": "2026-01-15T00:00:00+00:00",
// "active": true,
// "price_display": "$19.99"
// }
// ── PHP 8.1 Enums — encode automatically ─────────────────────────
enum Status: string
{
case Active = 'active';
case Inactive = 'inactive';
case Pending = 'pending';
}
enum Priority: int
{
case Low = 1;
case Medium = 2;
case High = 3;
}
$statusData = ['status' => Status::Active, 'priority' => Priority::High];
echo json_encode($statusData);
// {"status":"active","priority":3} — BackedEnum values encode as their backing type
// ── Nested JsonSerializable objects ───────────────────────────────
class Order implements \JsonSerializable
{
/** @param Product[] $items */
public function __construct(
private int $id,
private User $customer,
private array $items
) {}
public function jsonSerialize(): array
{
return [
'id' => $this->id,
'customer' => $this->customer, // User::jsonSerialize() called automatically
'items' => $this->items, // each Product::jsonSerialize() called automatically
'total' => array_sum(array_column(
array_map(fn($p) => $p->jsonSerialize(), $this->items),
'price'
)),
];
}
}PHP 8.1 BackedEnum cases encode correctly with json_encode() without implementing JsonSerializable — string-backed enums encode as their string value, int-backed enums as their integer value. Pure (unit) enums without backing values cannot be JSON-encoded and throw a TypeError. For DateTime encoding, always use DateTimeInterface::ATOM (ISO 8601) or DateTimeInterface::RFC3339_EXTENDED (includes milliseconds) as the format — never use the default object encoding, which produces an unreadable internal representation.
Streaming JSON HTTP Responses
For standard JSON API responses, set the Content-Type: application/json header and echo the encoded JSON. For large datasets, streaming with NDJSON (Newline-Delimited JSON) avoids loading all records into memory at once — yield one JSON object per line using PHP generators and flush the output buffer after each chunk.
<?php
// ── Standard JSON response ─────────────────────────────────────────
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
$data = ['status' => 'ok', 'users' => getUsersFromDb()];
echo json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
// ── Streaming large datasets: NDJSON ──────────────────────────────
// NDJSON (Newline-Delimited JSON): one JSON object per line
// Content-Type: application/x-ndjson
// Clients parse each line independently — no need to buffer the entire response
header('Content-Type: application/x-ndjson; charset=utf-8');
header('Transfer-Encoding: chunked');
header('X-Accel-Buffering: no'); // Disable Nginx buffering for streaming
// Disable PHP output buffering
if (ob_get_level()) {
ob_end_clean();
}
// Use a generator to avoid loading all rows into memory
function streamUsersFromDb(\PDO $pdo): \Generator
{
$stmt = $pdo->query('SELECT id, name, email, created_at FROM users ORDER BY id');
while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
yield $row;
}
}
$flags = JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
foreach (streamUsersFromDb($pdo) as $row) {
echo json_encode($row, $flags) . "\n";
// Flush after each row (or after every N rows for performance)
if (ob_get_level() > 0) {
ob_flush();
}
flush();
}
// ── Chunked JSON array streaming ───────────────────────────────────
// For clients expecting a JSON array, stream array chunks manually
header('Content-Type: application/json; charset=utf-8');
$stmt = $pdo->query('SELECT id, name FROM products');
$first = true;
echo '[';
while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
if (!$first) {
echo ',';
}
echo json_encode($row, $flags);
$first = false;
flush();
}
echo ']';
// ── Memory-efficient JSON for large files ─────────────────────────
// Avoid: $all = file_get_contents('huge.json'); // loads entire file
// Use: stream parsing with halaxa/json-machine (Composer)
// composer require halaxa/json-machine
use JsonMachine\Items;
foreach (Items::fromFile('huge.json') as $key => $value) {
processRecord($key, $value); // one record at a time, constant memory
}NDJSON is the preferred format for streaming large datasets — each line is a complete, parseable JSON value, so clients can start processing records as they arrive without waiting for the entire response. Set X-Accel-Buffering: no when behind Nginx to disable proxy buffering, which would otherwise accumulate the entire response before forwarding it to the client. For PHP-FPM deployments, also set fastcgi_buffering off in your Nginx location block. The halaxa/json-machine library provides memory-efficient JSON streaming for reading large files without loading them entirely into memory.
Guzzle HTTP Client JSON Integration
Guzzle is the standard HTTP client for PHP JSON API requests. The json request option automatically encodes the provided array as JSON and sets the Content-Type: application/json header. Response bodies are decoded with json_decode()after retrieving the body string. Guzzle's exception hierarchy handles HTTP errors, connection failures, and timeouts.
<?php
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException; // 4xx errors
use GuzzleHttp\Exception\ServerException; // 5xx errors
use GuzzleHttp\Exception\ConnectException; // connection/timeout errors
use GuzzleHttp\Exception\RequestException; // catch-all for request errors
$client = new Client([
'base_uri' => 'https://api.example.com',
'timeout' => 10.0,
'headers' => [
'Accept' => 'application/json',
'Authorization' => 'Bearer ' . $token,
],
]);
// ── GET request — decode response body ───────────────────────────
try {
$response = $client->request('GET', '/users/123');
$user = json_decode(
(string) $response->getBody(),
true,
512,
JSON_THROW_ON_ERROR
);
echo $user['name'];
} catch (ClientException $e) {
// 4xx — read the error body
$errorBody = json_decode((string) $e->getResponse()->getBody(), true);
echo $errorBody['message'] ?? 'Client error: ' . $e->getCode();
} catch (ServerException $e) {
// 5xx — server-side failure
error_log('Server error: ' . $e->getMessage());
} catch (ConnectException $e) {
// Network down, DNS failure, or timeout
error_log('Connection failed: ' . $e->getMessage());
}
// ── POST with JSON body — use the 'json' option ───────────────────
// Guzzle automatically sets Content-Type: application/json
$response = $client->request('POST', '/users', [
'json' => [
'name' => 'Alice',
'email' => 'alice@example.com',
'roles' => ['admin', 'editor'],
],
]);
$created = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);
echo $created['id'];
// ── Disable automatic exception throwing for 4xx/5xx ─────────────
// Set http_errors => false to handle all status codes manually
$response = $client->request('GET', '/users/999', ['http_errors' => false]);
$status = $response->getStatusCode();
$body = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);
if ($status === 404) {
echo 'User not found: ' . ($body['message'] ?? 'unknown');
} elseif ($status === 200) {
echo $body['name'];
}
// ── Async JSON requests ────────────────────────────────────────────
use GuzzleHttp\Promise;
$promises = [
'users' => $client->getAsync('/users'),
'products' => $client->getAsync('/products'),
];
$results = Promise\Utils::unwrap($promises);
$users = json_decode((string) $results['users']->getBody(), true);
$products = json_decode((string) $results['products']->getBody(), true);
// ── Using query parameters with JSON responses ─────────────────────
$response = $client->request('GET', '/users', [
'query' => [
'page' => 1,
'per_page' => 50,
'filter' => 'active',
],
]);
// GET /users?page=1&per_page=50&filter=active
// ── Middleware: log all JSON request/response pairs ────────────────
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
$stack = HandlerStack::create();
$stack->push(Middleware::tap(
function (RequestInterface $req) { /* log request */ },
function (ResponseInterface $res) { /* log response */ }
));
$clientWithLogging = new Client(['handler' => $stack]);Always set a timeout value on your Guzzle client — the default is no timeout, meaning a slow API can hang your PHP process indefinitely. A value of 10-30 seconds is appropriate for most API calls; use a shorter timeout (2-5 seconds) for health checks or user-facing requests where latency matters. When decoding Guzzle response bodies, cast the body to string first: (string) $response->getBody() — the body is a stream object, not a string, and calling json_decode() on the stream object directly causes a TypeError in PHP 8.
Laravel and Symfony JSON Responses
Laravel and Symfony both provide dedicated JSON response classes that handle encoding, headers, and status codes. Laravel's JsonResource and Symfony's JsonResponse are the idiomatic choices for JSON API controllers — prefer them over manual json_encode() and header() calls, which bypass framework middleware and response normalization.
<?php
// ── Laravel: response()->json() ───────────────────────────────────
// In a controller or route closure:
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class UserController extends Controller
{
public function show(int $id): JsonResponse
{
$user = User::findOrFail($id); // 404 on not found
return response()->json(
['data' => $user],
200,
['X-Request-Id' => request()->id()] // custom headers as third arg
);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users',
]);
$user = User::create($validated);
return response()->json(['data' => $user], 201);
}
}
// ── Laravel: JsonResource for API resources ────────────────────────
// app/Http/Resources/UserResource.php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'created_at' => $this->created_at->toIso8601String(),
// Conditional field — only include if user can see it
'admin' => $this->when($request->user()?->isAdmin(), $this->is_admin),
];
}
}
// In controller:
return UserResource::make($user); // single resource
return UserResource::collection(User::paginate(20)); // collection with pagination meta
// ── Laravel: disable data wrapper globally ────────────────────────
// In AppServiceProvider::boot():
// JsonResource::withoutWrapping();
// Now returns {"id":1,...} instead of {"data":{"id":1,...}}
// ── Symfony: JsonResponse ─────────────────────────────────────────
use Symfony\Component\HttpFoundation\JsonResponse;
class UserController extends AbstractController
{
#[Route('/users/{id}', methods: ['GET'])]
public function show(int $id): JsonResponse
{
$user = $this->userRepository->find($id);
if (!$user) {
return new JsonResponse(['error' => 'User not found'], 404);
}
return new JsonResponse([
'id' => $user->getId(),
'name' => $user->getName(),
'email' => $user->getEmail(),
]);
}
// Using Symfony Serializer for complex objects
#[Route('/users', methods: ['GET'])]
public function list(SerializerInterface $serializer): JsonResponse
{
$users = $this->userRepository->findAll();
$json = $serializer->serialize($users, 'json', [
'groups' => ['user:read'], // use serialization groups to control output
]);
return JsonResponse::fromJsonString($json); // avoid double-encoding
}
}
// Symfony Serializer: control output with groups and attributes
use Symfony\Component\Serializer\Annotation\Groups;
class User
{
#[Groups(['user:read', 'user:write'])]
public int $id;
#[Groups(['user:read', 'user:write'])]
public string $name;
#[Groups(['user:admin'])] // only in admin context
public string $email;
}Laravel's JsonResource::collection() automatically detects pagination — if you pass a LengthAwarePaginator, the response includes a meta object with current_page, last_page, total, and a links object with pagination URLs. Use JsonResponse::fromJsonString($json) in Symfony when you already have a JSON string (e.g., from the Serializer component) to avoid double-encoding. Symfony's Serializer with circular reference handling is configured with the circular_reference_handlercontext option — set it to a callback that returns the object's ID, preventing infinite loops when serializing bidirectional Doctrine entity relationships.
Key Terms
- json_encode
- A PHP built-in function that converts a PHP value (array, object, scalar, or null) to a JSON string. Accepts a bitmask of flags as the second argument and a depth limit (default 512) as the third. Returns a JSON string on success or
falseon failure — useJSON_THROW_ON_ERRORto throwJsonExceptioninstead of returningfalse. Only public properties of objects are encoded unless the object implementsJsonSerializable. PHP arrays with sequential integer keys (0, 1, 2, ...) encode as JSON arrays; arrays with string keys or non-sequential integer keys encode as JSON objects. - json_decode
- A PHP built-in function that parses a JSON string into a PHP value. The second parameter (
associative) controls return type:truereturns associative arrays for JSON objects,false(default) returnsstdClassobjects. Returnsnullon error AND for valid JSONnullinput — distinguish these withjson_last_error()orJSON_THROW_ON_ERROR. The third parameter sets the maximum nesting depth (default 512). Use theJSON_BIGINT_AS_STRINGflag (fourth parameter) to preserve large integers as strings instead of converting them to imprecise floats. - JsonSerializable
- A PHP interface in the global namespace defining a single method:
public function jsonSerialize(): mixed. Whenjson_encode()encounters an object that implementsJsonSerializable, it callsjsonSerialize()and encodes the returned value instead of the object's public properties. The method can return any JSON-encodable value: an array (most common), a scalar, null, or anotherJsonSerializableobject. Use it to control which properties appear in JSON, rename properties (snake_case in JSON vs camelCase in PHP), transform values (cents to dollars, DateTime to ISO 8601 string), and include computed properties. - JSON_THROW_ON_ERROR
- A PHP constant (integer flag) introduced in PHP 7.3 that changes the error behavior of
json_encode()andjson_decode(). Without this flag, failures returnfalseornullsilently — errors are only discoverable viajson_last_error(). WithJSON_THROW_ON_ERROR, failures throw aJsonException(extendsRuntimeException) with a descriptive message and thejson_last_error()value as the exception code. Combine with other flags using bitwise OR:JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE. Use it in all production code — silent JSON failures cause hard-to-debug data corruption. - stdClass
- PHP's generic empty class, used as the default return type for
json_decode()when the second parameter isfalseor omitted. AstdClassinstance stores properties dynamically —json_decode('{"name":"Alice"}')->namereturns"Alice". Unlike associative arrays,stdClassobjects cannot be passed to array functions (array_map,array_keys,count) without casting:(array) $obj. The name comes from "standard class". Always preferjson_decode($json, true)(associative array mode) in application code to avoid the confusion between object and array access syntax. - JsonResource
- A Laravel class (
Illuminate\Http\Resources\Json\JsonResource) that transforms Eloquent model instances into JSON API responses. Extend it and implementtoArray(Request $request): arrayto define the JSON representation. UseUserResource::make($user)for a single resource (wrapped in{"data": {...}}by default) andUserResource::collection($users)for a collection. JsonResource integrates with Laravel pagination — passing a paginator tocollection()automatically addsmetaandlinkspagination objects. CallJsonResource::withoutWrapping()globally to disable thedataenvelope for REST APIs that do not use JSON:API conventions.
FAQ
What is the difference between json_decode($json) and json_decode($json, true)?
json_decode($json) with no second argument returns stdClass objects for JSON objects — you access properties with arrow syntax: $obj->name. json_decode($json, true) returns PHP associative arrays — you access values with bracket syntax: $arr["name"]. The single flag controls the return type for all nested objects too: there is no mixed mode. The practical difference matters immediately when passing decoded data to other functions — array_map, array_filter, count, and most framework helpers expect arrays and throw TypeError on stdClass objects. Always pass true as the second argument in application code unless you specifically need object property syntax. The stdClass mode exists primarily for legacy code and cases where the JSON structure is unknown at write time.
How do I handle json_encode() errors in PHP?
PHP 7.3+ best practice: pass JSON_THROW_ON_ERROR as the flags argument — json_encode($data, JSON_THROW_ON_ERROR) — and wrap the call in a try/catch (\JsonException $e) block. Before PHP 7.3, check the return value: $json = json_encode($data); if ($json === false) { throw new \RuntimeException(json_last_error_msg()); }. Never ignore a false return. The most common errors: JSON_ERROR_UTF8 (invalid UTF-8 bytes — fix with mb_convert_encoding()), JSON_ERROR_RECURSION (circular references — implement JsonSerializable to break cycles), JSON_ERROR_INF_OR_NAN (float INF/NAN values — replace with null), and JSON_ERROR_DEPTH (nesting exceeds depth limit — increase with the third parameter). PHP 8.3 added json_validate() for checking JSON strings without full decoding.
How do I pretty-print JSON in PHP?
Pass JSON_PRETTY_PRINT as the flags argument: json_encode($data, JSON_PRETTY_PRINT). This adds 4-space indentation and newlines. Combine multiple flags with bitwise OR for the most useful combination: json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES). JSON_UNESCAPED_UNICODE keeps non-ASCII characters readable (é stays as é instead of é); JSON_UNESCAPED_SLASHES keeps URLs readable (https://example.com stays unescaped). Use pretty-printing only for debugging, log files, developer tools, and config file generation — never in production API responses, where the extra whitespace unnecessarily increases payload size and bandwidth. The output is always valid JSON, so no parsing changes are needed on the receiving end.
How do I encode non-ASCII characters in PHP JSON without escaping?
Pass JSON_UNESCAPED_UNICODE to json_encode(): json_encode($data, JSON_UNESCAPED_UNICODE). Without this flag, every non-ASCII character is escaped as \uXXXX — é becomes é, 中 becomes 中, and emoji become multi-character sequences. With JSON_UNESCAPED_UNICODE, characters are written as-is, reducing payload size by up to 70% for non-Latin content. Prerequisite: all string values must be valid UTF-8 — if they contain invalid bytes, json_encode() returns false (JSON_ERROR_UTF8). Fix encoding first with mb_convert_encoding($str, "UTF-8", "auto") or ensure your database connection uses utf8mb4. Combine with JSON_UNESCAPED_SLASHES and JSON_THROW_ON_ERROR for production API responses: JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR.
How do I implement a custom JSON serializer for a PHP class?
Implement the JsonSerializable interface by adding public function jsonSerialize(): mixed to your class. json_encode() calls jsonSerialize() automatically and encodes its return value. The method typically returns an array: return ['id' => $this->id, 'name' => $this->name, 'created_at' => $this->createdAt->format(\DateTimeInterface::ATOM)];. This lets you: expose only specific properties (exclude private fields like password hashes), rename properties (camelCase PHP to snake_case JSON), transform values (DateTime to ISO 8601, cents to dollars), and add computed properties not stored as class fields. For DateTime properties, always format as ISO 8601: $this->date->format(\DateTimeInterface::ATOM). PHP 8.1 BackedEnum values encode automatically without implementing JsonSerializable.
How do I return a JSON response in Laravel?
Three idiomatic approaches: (1) response()->json($data, $statusCode) — the simplest option for any array or scalar data; sets Content-Type: application/json, encodes with json_encode(), and returns the given HTTP status code. (2) JsonResource — extend Illuminate\Http\Resources\Json\JsonResource and define toArray(); return UserResource::make($user) for single resources or UserResource::collection($paginator) for paginated collections (automatic pagination metadata). (3) Return Eloquent models or collections directly from route closures — Laravel auto-encodes them. For API routes, disable the data wrapper globally with JsonResource::withoutWrapping() in AppServiceProvider::boot(). Set custom HTTP status codes on JsonResource responses with UserResource::make($user)->response()->setStatusCode(201).
How do I make JSON API requests with Guzzle in PHP?
Use the json option key to send JSON: $client->request('POST', $url, ['json' => $data]). Guzzle automatically sets Content-Type: application/json and encodes the array with json_encode(). Decode the response: json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR) — cast the body to string first ((string)) since it is a stream object. Handle errors: catch ClientException for 4xx responses, ServerException for 5xx, and ConnectException for connection failures. Read error response bodies from the exception: json_decode((string) $e->getResponse()->getBody(), true). Set http_errors => false to handle all status codes manually without exceptions. Always set a timeout value — the default is no timeout.
What are the most common PHP json_decode() mistakes?
The six most common mistakes: (1) Omitting the true (associative) parameter — returns stdClass, causing TypeError when passed to array functions like array_keys or array_map. (2) Not distinguishing null return from error — json_decode() returns null both for invalid JSON and for valid JSON "null" input; use JSON_THROW_ON_ERROR or check json_last_error(). (3) Passing a non-string argument — in PHP 8+, passing null, false, or an array throws TypeError; validate input type before decoding. (4) Losing precision on large integers — JSON numbers exceeding 2^53 decode as imprecise floats; use JSON_BIGINT_AS_STRING. (5) Ignoring depth errors — JSON nested deeper than the depth limit silently returns null; check json_last_error() === JSON_ERROR_DEPTH. (6) Expecting false on failure — unlike json_encode(), json_decode() returns null (not false) on error.
Further reading and primary sources
- PHP Manual: json_encode() — Official PHP documentation for json_encode() flags, parameters, and error constants
- PHP Manual: json_decode() — Official PHP documentation for json_decode() return types, depth parameter, and flags
- PHP Manual: JsonSerializable — JsonSerializable interface reference with examples for custom object encoding
- Laravel HTTP Resources — Laravel JsonResource, API Resources, and collection response documentation
- Guzzle Documentation: Request Options — Guzzle json option, http_errors, and response body decoding reference