JsonSerializableでオブジェクトのJSON表現を自分で決める
JsonSerializable は、json_encode() したときの自分の表現をクラス側で決めるためのインターフェイスです。PHP 5.4から使え、jsonSerialize(): mixed を1つ実装するだけで済みます。
APIのレスポンスを組み立てるとき、コントローラで毎回オブジェクトを配列に詰め替えている、という現場をよく見ます。詰め替え処理があちこちに散らばって、片方だけ直し忘れる。あれは大体、JSON表現の置き場所を決めていないのが原因だと思っています。
何もしないと公開プロパティが素通しで出る
json_encode() にオブジェクトをそのまま渡すと、public なプロパティだけが出力されます。private や protected は無視されます。つまり、プロパティを private にした瞬間にJSONが空になる。
class User
{
public function __construct(
private int $id,
private string $name,
private string $passwordHash,
) {}
}
echo json_encode(new User(1, '田中', 'xxxx')); // {}
逆にすべて public にすると、今度は passwordHash まで出ていきます。カプセル化とJSON出力が正面からぶつかる場所で、ここを埋めるのが JsonSerializable です。
class User implements JsonSerializable
{
public function __construct(
private int $id,
private string $name,
private string $passwordHash,
) {}
public function jsonSerialize(): array
{
return [
'id' => $this->id,
'name' => $this->name,
];
}
}
echo json_encode(new User(1, '田中', 'xxxx'), JSON_UNESCAPED_UNICODE);
// {"id":1,"name":"田中"}
// フラグを付けないと name は "\u7530\u4e2d" と出る
インターフェイス側の宣言は mixed ですが、実装側で array のように狭めるのは問題ありません。戻り値の型は共変なので、より具体的な型を書けます。読む側にとっても、そのほうが親切だと思います。
返した値はどう扱われる?
jsonSerialize() の戻り値は、そのオブジェクトの代わりに json_encode() がもう一度エンコードします。配列でも文字列でも数値でも、別のオブジェクトでも構いません。
final class Money implements JsonSerializable
{
public function __construct(
private int $amount,
private string $currency,
) {}
// スカラーを返してもよい
public function jsonSerialize(): string
{
return $this->amount . ' ' . $this->currency;
}
}
class Order implements JsonSerializable
{
public function __construct(
private int $id,
private Money $total,
) {}
public function jsonSerialize(): array
{
return ['id' => $this->id, 'total' => $this->total];
}
}
echo json_encode(new Order(7, new Money(1200, 'JPY')));
// {"id":7,"total":"1200 JPY"}
入れ子の Money も、その場で jsonSerialize() が呼ばれます。中で自分から json_encode() を呼ぶ必要はありません。呼んでしまうと二重エンコードになって、JSONの中に文字列としてのJSONが埋まる、あの見慣れた事故になります。
配列のキーが飛ぶとオブジェクトになる
実務で一番踏むのはここでした。PHPの配列がJSONの配列([])になるのは、キーが 0 から連番で並んでいるときだけです。1つでも飛ぶと、キー付きのオブジェクト({})に化けます。
class UserList implements JsonSerializable
{
public function __construct(private array $users) {}
public function jsonSerialize(): array
{
// 退会ユーザーを除外
return array_filter($this->users, fn($u) => $u->isActive());
}
}
// 2番目が除外されると
// {"0":{...},"2":{...}} ← 配列のつもりがオブジェクト
array_filter() はキーを保持するので、こうなります。フロント側は配列を期待して map() を呼んでいて、そこで落ちる。しかも全員アクティブなときは再現しないので、原因にたどり着くまでが長いです。
public function jsonSerialize(): array
{
return array_values(
array_filter($this->users, fn($u) => $u->isActive())
);
}
array_values() を挟むだけです。コレクション的なクラスで jsonSerialize() を書くときは、返す直前に必ず通す、くらいの決めごとにしてしまってよいと思います。
空のときに [] にするか {} にするか
関連して、空配列は必ず [] になります。連想配列を意図していた場所が空だと、型が揺れます。
echo json_encode(['meta' => []]); // {"meta":[]}
echo json_encode(['meta' => (object) []]); // {"meta":{}}
JSON_FORCE_OBJECT という定数もありますが、これは json_encode() 呼び出し全体に効いてしまい、配列にしたい部分まで巻き込みます。個別に (object) でキャストするか、素直に new stdClass() を返すほうが事故が少ないと感じています。
エラーはどこから飛んでくるのか
json_encode() は失敗時に false を返します。JSON_THROW_ON_ERROR(PHP 7.3以降)を付けると、代わりに JsonException が投げられます。不正なUTF-8や、深すぎる入れ子($depth の既定値は512)で起きます。
try {
$json = json_encode($order, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
} catch (JsonException $e) {
// json_last_error_msg() 相当のメッセージが入る
error_log('encode failed: ' . $e->getMessage());
}
注意したいのは、jsonSerialize() の中で投げた例外はこのフラグとは無関係に、そのまま json_encode() を突き抜けて出てくることです。中でリポジトリを叩いて遅延ロードするような実装にしていると、DB例外がレスポンス生成の一番外側で飛んできて面食らいます。jsonSerialize() は手元のデータを整形するだけ、と決めておくのが無難です。
enumの場合はどうなる?
バックド enum、つまり値を持つenumは、何もしなくてもその値としてエンコードされます。値を持たない純粋なenumはJSON表現が決められないため、エンコードしようとするとエラーになります。
enum Status: string
{
case Active = 'active';
case Banned = 'banned';
}
echo json_encode(['status' => Status::Active]); // {"status":"active"}
enum Color // 値を持たない
{
case Red;
}
// json_encode(Color::Red) はエラーになる
純粋なenumをどうしてもJSONに出したいなら、enum自身に JsonSerializable を実装できます。ただ、そこまでするなら最初からバックド enum にしたほうが素直かもしれません。バックド enum の表現を変えたいとき(ラベルも一緒に返したい、など)も同じく実装で上書きできます。
まとめ
JSON表現をクラスの中に置けるようになると、コントローラから詰め替えコードが消えます。private を守ったまま出力を制御できるのが本来の狙いで、実務で刺さるのは array_filter 後のキー飛びと、空配列が [] になる話のほうだったりします。この2つを知っているだけでも、フロントとの往復がだいぶ減るはずです。
逆に、表示都合のロジックが jsonSerialize() にどんどん溜まってきたら、それはモデルではなく別のプレゼンテーション層に置くべき仕事なのだと思います。線引きの目安として、私は「保存しているデータを並べ替えるだけか」を見ています。
よくある質問
Q. jsonSerialize() は serialize() にも影響しますか?
A. しません。JsonSerializable は json_encode() 専用です。PHPの serialize() の挙動を変えたい場合は __serialize() / __unserialize() を実装します。
Q. 戻り値の型に array と書いても大丈夫ですか?
A. 大丈夫です。インターフェイスの宣言は mixed ですが、戻り値の型は共変なので、実装側でより狭い型に絞れます。
Q. json_decode() で元のオブジェクトに戻せますか?
A. 戻りません。json_decode() は連想配列か stdClass を返すだけです。復元したいなら、名前付きコンストラクタ(fromArray() のような静的メソッド)を自分で用意することになります。