Typed objects instead of nested arrays
json_decode($json, true) gives you associative arrays, and every $data['customer']['address']['city'] lookup is a silent typo waiting to happen. Typed classes let your IDE autocomplete fields, let PHPStan or Psalm check them, and make a changed API fail loudly at the boundary instead of deep inside your code. This generator infers those classes from a sample: every distinct object shape gets a final class, nested objects are named after their keys, and array items take the singular form of the array’s name, so "orders": [...] holds Order objects.
The output starts with declare(strict_types=1);, so a value of the wrong type throws a TypeError when the object is built rather than being coerced.
Type mapping
- Strings, integers, decimals and booleans become
string,int,floatandbool. Integer values are accepted wherefloatis declared, which strict mode allows, so79and129.9can share afloatproperty. - A key missing from some objects or
nullin some becomes nullable (?string), andfromArray()reads it with?? nullso a missing key is not an error. - A position holding several JSON types becomes a union type such as
int|string; withnullas well,int|string|null. - Arrays are declared
array, the only native option, and a docblock gives the element type for static analysers:/** @var list<Order> */orarray<string, int>. Nested arrays nest the same way:list<list<int>>. - A position that is only
null, or an array that is always empty, gives no type to infer and is declaredmixed. - PHP integers are 64-bit. An integer beyond that range would become a lossy float in
json_decode, so its property is typedint|stringand the usage comment addsJSON_BIGINT_AS_STRING, which makes PHP return such numbers as exact strings. - Classes that would collide with reserved words (
List,Class,Default) get aModelsuffix.
Options
Root type name names the top-level class, in PascalCase. A JSON array at the root generates the element class, and the usage comment shows the array_map call that builds a list of them.
Namespace adds a namespace declaration such as App\Dto, matching your PSR-4 autoloading; leave it empty for none. An invalid value is reported and left out rather than producing code that does not parse.
readonly properties (on by default) declares every promoted property public readonly, so objects are immutable after construction, which suits data that came from an API. Turn it off for mutable models.
fromArray() factory adds public static function fromArray(array $data): self, which calls the constructor with named arguments, converts nested arrays to their classes (including lists of objects and nullable children, using isset), and leaves scalars as decoded. Without it you get plain classes for a serializer such as Symfony Serializer or Valinor to hydrate.
camelCase property names (on by default) turns user_id into $userId; fromArray() still reads the original key, so nothing else needs mapping. With it off, keys that are valid PHP variable names are kept and invalid characters become underscores.
Using the classes
Decode with json_decode($json, true, 512, JSON_THROW_ON_ERROR) so malformed JSON throws, then pass the array to Root::fromArray(). Keys present in the JSON but not in the class are ignored, so additive API changes do not break anything. For calling the same API from PHP, the cURL to PHP converter writes the request; tidy edited classes with the PHP formatter.
Examples
Customer with addresses
customerId maps back to customer_id in fromArray; addresses is a list of Address objects and postcode is ?string because one value is null.
{
"customer_id": 1042,
"email": "[email protected]",
"vip": true,
"addresses": [
{ "line1": "1 Raffles Place", "city": "Singapore", "postcode": "048616" },
{ "line1": "22 Marina Way", "city": "Singapore", "postcode": null }
]
}<?php
declare(strict_types=1);
namespace App\Dto;
// $value = Customer::fromArray(json_decode($json, true, 512, JSON_THROW_ON_ERROR));
final class Customer
{
public function __construct(
public readonly int $customerId,
public readonly string $email,
public readonly bool $vip,
/** @var list<Address> */
public readonly array $addresses,
) {
}
/** @param array<string, mixed> $data */
public static function fromArray(array $data): self
{
return new self(
customerId: $data['customer_id'],
email: $data['email'],
vip: $data['vip'],
addresses: array_map(static fn (array $item): Address => Address::fromArray($item), $data['addresses']),
);
}
}
final class Address
{
public function __construct(
public readonly string $line1,
public readonly string $city,
public readonly ?string $postcode,
) {
}
/** @param array<string, mixed> $data */
public static function fromArray(array $data): self
{
return new self(
line1: $data['line1'],
city: $data['city'],
postcode: $data['postcode'],
);
}
}
Plain mutable classes for a serializer
Without readonly and fromArray the classes only declare typed promoted properties, ready for a hydrator.
{
"orderNumber": "SO-2207",
"total": 129.9,
"lines": [{ "sku": "KB-104", "qty": 1 }],
"meta": {}
}<?php
declare(strict_types=1);
// $data = json_decode($json, true, 512, JSON_THROW_ON_ERROR); // then pass its fields to new Order(...)
final class Order
{
public function __construct(
public string $orderNumber,
public float $total,
/** @var list<Line> */
public array $lines,
public Meta $meta,
) {
}
}
final class Line
{
public function __construct(
public string $sku,
public int $qty,
) {
}
}
final class Meta
{
public function __construct()
{
}
}
Mixed values and a big integer
The root array produces a RootElement class; id is int|string with JSON_BIGINT_AS_STRING in the usage comment, and value is int|string.
[
{ "id": 98765432109876543210, "value": 1 },
{ "id": 5, "value": "high" }
]<?php
declare(strict_types=1);
// The JSON root is list<RootElement>:
// $items = array_map(static fn (array $item): RootElement => RootElement::fromArray($item), json_decode($json, true, 512, JSON_THROW_ON_ERROR | JSON_BIGINT_AS_STRING));
final class RootElement
{
public function __construct(
public readonly int|string $id,
public readonly int|string $value,
) {
}
/** @param array<string, mixed> $data */
public static function fromArray(array $data): self
{
return new self(
id: $data['id'],
value: $data['value'],
);
}
}
Common errors and how to fix them
| Error | Cause | Fix |
|---|---|---|
Trailing comma before '}'Explained | The sample has a comma after the last property, which JavaScript allows and JSON does not. | Remove the comma, or let the JSON formatter fix the sample first. |
Some integers do not fit PHP's 64-bit int; they are typed int|string.Explained | A warning: an ID or amount in the sample is larger than PHP_INT_MAX. | Decode with JSON_BIGINT_AS_STRING as the usage comment shows, so those values arrive as exact strings. |
TypeError: Argument #1 ($id) must be of type int, string given | A real response sends a type the sample did not contain, and strict_types refuses to convert it. | Widen the property type, for example to int|string, or add such a value to the sample and regenerate. |
Undefined array key "…" | A key that was in every sample object is missing from real data, but fromArray() reads it as required. | Make the property nullable and read the key with ?? null, or add an object without that key to the sample. |
Frequently asked questions
Which PHP version do I need?
PHP 8.1 or later, for readonly properties. With the readonly option turned off the classes also run on PHP 8.0, which introduced constructor promotion, union types and mixed.
Why are arrays typed as array with a docblock?
PHP has no generic array types, so the native type can only say array. The @var list<Item> docblock tells PHPStan, Psalm and IDEs what the elements are.
Does it work with Laravel?
Yes. The classes are framework-free; build them from $request->json()->all() or Http::get(…)->json() with fromArray().
Is my JSON uploaded?
No. The sample is parsed and the classes are written by JavaScript running in this tab; nothing goes over the network.