Understanding JSON → PHP
Modern PHP DTOs are surprisingly tight.
What constructor property promotion and readonly properties did to PHP boilerplate, and the gotchas in json_decode that catch every PHP developer.
The history, briefly.
PHP got declared property types in 7.4 (2019), constructor property promotion in 8.0 (2020), readonly properties in 8.1 (2021), and asymmetric visibility / property hooks in 8.4 (2024). Modern PHP code looks almost nothing like the PHP 5 era; a JSON DTO can be a 5-line class instead of a 50-line one. Codegen tools that target 8.0+ should emit promoted, readonly properties.
Constructor property promotion.
PHP 8.0's biggest ergonomic win. public function __construct(public readonly int $userId, public readonly string $name) {} declares the property, types it, sets visibility, marks it readonly and assigns it from the constructor argument — all in one line per field. The old shape (declare the property, then assign it inside the constructor body) is now legacy. Codegen tools should emit the promoted form by default.
json_decode and the assoc gotcha.
json_decode($s) returns a generic stdClass by default — attribute access via $data->name. json_decode($s, true) returns an associative array — $data['name']. Most modern projects want the second form because typed DTOs are populated by hand from the array; tools like Symfony Serializer go further and populate the typed DTO directly. Forgetting the true flag is a perennial source of "why is this object behaving weird" bugs.
A worked example.
From { "user_id": 7, "name": "Q", "avatar": null }: final class User {
public function __construct(
public readonly int $userId,
public readonly string $name,
public readonly ?string $avatar = null,
) {}
} Five lines for a fully-typed immutable DTO. The nullable type ?string is PHP's ?T shorthand for T|null. final prevents subclassing; readonly prevents post-construction mutation.
Constructing from json_decode array
new User(userId: $a['user_id'], name: $a['name'], avatar: $a['avatar'] ?? null)
Named arguments map JSON keys to constructor params.
$user = new User(...$decoded);
= Typed, immutable instance
JsonSerializable for the reverse direction.
To go from object back to JSON, implement the JsonSerializable interface and a jsonSerialize() method that returns an array. Then json_encode($user) calls your method and emits the array as JSON. Codegen tools that emit JsonSerializable wire up the snake_case key names back to the camelCase property names — the same translation as the input direction, just reversed.
Strict types.
Add declare(strict_types=1); at the top of the file and PHP stops doing its old "string '7' is also int 7" coercion. Passing a string into an int parameter throws a TypeError. For DTOs this is what you want — fail fast on input mismatch rather than silently coerce and have the bug show up in a later layer. Codegen tools should emit the directive by default; almost every modern PHP project does.
Enums for tagged values.
PHP 8.1 added enums — both pure (unit-only) and backed (with string or int values). A backed enum is the right type for a status field: enum Status: string { case Active = 'active'; case Banned = 'banned'; }. To round-trip with JSON, use Status::from('active') and $status->value. The codegen tool that recognises a known-set string field emits an enum; one that doesn't emits string and you cast by hand.