The default is an untyped array, and that's the problem
json_decode($json, true) is how most PHP code reads a JSON response, and it's genuinely fine for a quick script — but it hands back a plain associative array with no type information at all. A typo in a key name ($data['emial'] instead of $data['email']) returns null silently instead of failing, and nothing — not your IDE, not PHPStan, not Psalm — can catch it before it ships, because as far as static analysis is concerned, an associative array's keys are just runtime strings.
$data = json_decode($json, true);
echo $data['emial']; // typo — silently null, no warning, no error, until it's in productionConstructor property promotion makes the fix cheap
Before PHP 8.0, hydrating JSON into a typed object meant writing a constructor, a full set of property declarations, and an assignment for each one — enough boilerplate that plenty of teams reasonably decided it wasn't worth it for every API response shape. Constructor property promotion collapses all of that into the parameter list itself:
final class User
{
public function __construct(
public string $name,
public string $email,
public int $age,
public bool $active,
) {}
public static function fromArray(array $data): self
{
return new self(
name: $data['name'],
email: $data['email'], // typo here is now a fatal error at construction, not a silent null
age: $data['age'],
active: $data['active'],
);
}
}
$user = User::fromArray(json_decode($json, true));
echo $user->email; // IDE autocomplete works, PHPStan/Psalm both understand this fullyThe same key-name typo above now fails loudly and immediately — either as an Undefined array key warning at the fromArray call, or, if the property is non-nullable and the key is genuinely missing, a TypeError. Either way, you find out at the point of hydration, not three function calls deeper when something unrelated breaks.
Nested objects and arrays
A real API response is rarely flat — nested objects become their own typed classes, and arrays of objects hydrate with array_map:
final class Address
{
public function __construct(
public string $city,
public string $country,
) {}
public static function fromArray(array $data): self
{
return new self(city: $data['city'], country: $data['country']);
}
}
final class Order
{
public function __construct(
public string $id,
/** @var OrderItem[] */
public array $items,
public Address $shippingAddress,
) {}
public static function fromArray(array $data): self
{
return new self(
id: $data['id'],
items: array_map(fn(array $i) => OrderItem::fromArray($i), $data['items']),
shippingAddress: Address::fromArray($data['shippingAddress']),
);
}
}The @var OrderItem[] docblock above the items property matters more than it looks — PHP's native type system has no generic array<T> syntax, so without the docblock, static analysis tools only know items is *an* array, not an array of OrderItem. PHPStan and Psalm both read that docblock convention and will correctly flag $order->items[0]->badProperty as an error once it's there.
Generating this by hand doesn't scale past a couple of endpoints
Writing this pattern out for one or two response shapes is a few minutes of work; doing it for a 40-endpoint API by hand is not a good use of anyone's afternoon. JSONKit's JSON to PHP generator takes a real JSON sample and produces exactly this shape — typed constructor-promoted classes, nested classes for nested objects, and a fromArray() hydrator for each — directly in your browser, so you're editing and adjusting generated code rather than typing every property declaration from scratch.