01 · THE IDEAREAD → TRY → LEARN
Why control the JSON shape?
When you pass an object to json_encode(), PHP does not know which parts of it are meant for the outside world. Without help, it exposes only the public properties. Private and protected state is skipped, so a class with only private properties encodes as an empty object. That default is easy to break by accident: add a public property later and it leaks into your output.
The JsonSerializable interface fixes this. A class that implements it declares one method:
public function jsonSerialize(): mixed
json_encode() calls this method and encodes whatever it returns. The JSON format becomes an explicit contract, separate from how the object stores its data.
A first example
The class below keeps a price in integer cents but publishes it as a decimal string. It also chooses the key names. Integer arithmetic avoids floating-point surprises.
final class Price implements JsonSerializable
{
public function __construct(private string $sku, private int $cents) {}
public function jsonSerialize(): array
{
return [
'sku' => $this->sku,
'price' => sprintf('%d.%02d', intdiv($this->cents, 100), $this->cents % 100),
];
}
}
echo json_encode(new Price('A-1', 1999), JSON_UNESCAPED_SLASHES), "\n";{"sku":"A-1","price":"19.99"}
The return value is an ordinary array, so you can reshape, rename, or compute anything. An associative array becomes a JSON object and a list becomes a JSON array.
The return value is encoded again
jsonSerialize() may return any value, such as a scalar, an array, or even other objects. If it returns objects that also implement JsonSerializable, they are serialized in turn. Nesting therefore works naturally:
final class Tag implements JsonSerializable
{
public function __construct(private string $label) {}
public function jsonSerialize(): string
{
return strtolower($this->label);
}
}
final class Post implements JsonSerializable
{
public function __construct(private string $title, private array $tags) {}
public function jsonSerialize(): array
{
return ['title' => $this->title, 'tags' => $this->tags];
}
}
$post = new Post('Intro', [new Tag('PHP'), new Tag('JSON')]);
echo json_encode($post), "\n";{"title":"Intro","tags":["php","json"]}
Each Tag becomes a plain string, so the tags list is a JSON array of strings. The narrower return types string and array are valid because both are subtypes of mixed.
Default behavior versus an explicit contract
Compare a class with no interface to one that implements it:
final class Plain
{
public function __construct(private string $secret, private string $id) {}
}
final class Explicit implements JsonSerializable
{
public function __construct(private string $secret, private string $id) {}
public function jsonSerialize(): array
{
return ['id' => $this->id];
}
}
echo json_encode(new Plain('s3cret', '7')), "\n";
echo json_encode(new Explicit('s3cret', '7')), "\n";{}
{"id":"7"}
The first object produces {} because nothing is public. The second produces exactly the fields you chose, and secret stays out because the method never returns it.
Design guidelines
- Return only data that is safe to publish. Treat the method as an allow-list.
- Keep the method free of side effects. It should just describe the current state.
- Use
array_values()when you need a list. An array with gaps in its keys would otherwise encode as an object. - Build conditional output by assembling the array first and adding optional keys with an
if. - Key order in the returned array is the key order in the JSON text, which helps tests stay stable.
Exercise contract
Write user_json(string $name, string $email, array $roles, bool $includeEmail): string. Inside it, create an object whose class implements JsonSerializable, then return json_encode() of that object with JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES.
The object must serialize to an object with name first and roles second. Roles must be a re-indexed list. Add an email key last, and only when $includeEmail is true. Because the email is a private property, it must never appear unless your method adds it.
Boundary cases to consider: an empty role list must produce [], an empty name must stay an empty string, and non-ASCII text must not be escaped. Your tests compare the returned string, not printed output.
Recap
JsonSerializable turns JSON output into a deliberate API. Implement jsonSerialize(), return the exact shape you want, and let json_encode() handle nested values for you.
Functions · Arrays · Types
- Implement JsonSerializable for an explicit data shape
- Implement and test a deterministic PHP 8.4 function