Details on supported declarations, construction, and JSON round trips.
The table describes supported behavior and its limits. Unsupported behavior must not be relied on, even if a particular value happens to decode.
| Target or value | Status | Limits |
|---|---|---|
| Concrete classes | Supported at the root | The JSON root must be an object. Recognized member names are passed as named constructor arguments, so they must match the constructor parameter names or their public properties' #[Field] names. Each parameter must have a same-named public instance property so json_encode() can expose its value. Additional members are ignored, unless a property collects them; see Other members. |
| Readonly classes | Supported at the root | The same constructor rules as other concrete classes apply. |
string, int, float, and bool constructor fields |
Supported | Values must have the declared type. An integer JSON value is also valid for a float field because the declaration restores it as a PHP float. Other implicit scalar coercions are rejected. |
null, nullable scalar fields, and literal true and false fields |
Supported | A non-null value must still match the non-null member of a nullable type. Literal Boolean fields accept only their declared value. |
| Collection fields | Limited | PHPDoc list<T> and non-empty-list<T> declarations are decoded and validated for constructor fields and public properties, where T is string, int, float, bool, a backed enum, a final class, a supported union of those types and null, true, or false, or a discriminated union of several final classes. Lists, tuples, and maps may nest recursively in any combination, including nullable nested collections and supported unions, with the same rules at every level. Empty JSON arrays are rejected for non-empty-list<T>. Positional tuples such as array{int, string} require exactly the declared positions and validate each independently, using the same supported item types. array{} preserves empty JSON arrays. Explicit consecutive indexes starting at zero and optional trailing positions are supported, such as array{0: int, 1?: string}; omitted positions remain absent. Nonempty maps use non-empty-array<string, T> or its alias non-empty-map<string, T>; maps that may be empty use ArrayObject<string, T>, or ArrayObject<array-key, T> to accept every member name. For every map form, T supports the same declarations. JSON arrays are rejected for maps, {} is rejected for nonempty-array maps, and member names that PHP converts to integer keys are rejected unless the key type is array-key or `int |
| Nested class fields | Supported for final classes | JSON objects are recursively converted to final classes declared directly on constructor fields, including readonly classes, nullable fields, self declarations that resolve to a final class, and unions with backed enums, scalar, and null members. A union of several final classes is supported when a member of the objects identifies the class; see Discriminated unions. Non-final declarations, including parent, are rejected because their values may be subclasses. Classes with a private or protected constructor are rejected, even when the member is missing or null. A final class may join a union with a list or tuple; maps and classes cannot share a union because both encode as JSON objects. |
| Backed enum fields | Supported in direct constructor fields, public properties, unambiguous unions, lists, and maps | String-backed and int-backed values are converted without coercion, including nullable enum fields, lists, non-empty-array<string, BackedEnum> and ArrayObject<string, BackedEnum> maps, unions where scalar members use different JSON types from the enum backing type, and unions of backed enums whose case values do not overlap. Non-backed enum values cannot be decoded. A non-backed enum without custom serialization may join a union with supported members, including null. |
| General union types | Limited | Nullable scalar declarations, one final class or a discriminated union of several final classes alongside a backed enum and scalar or null members, backed enum/scalar unions with distinct JSON types, and unambiguous unions of backed enums are supported. Scalar members must use JSON types distinct from the enum's backing type. Supported collection declarations may join field, tuple-position, list-item, and map-value unions, with at most one member for each JSON array or object shape. |
| Intersection types | Rejected | JSON does not identify a concrete class that satisfies the intersection. |
| Interfaces and abstract classes | Rejected at the root and in field types | Root targets, direct constructor field declarations, union members, list item declarations, and map value declarations return dedicated errors because JSON does not identify a concrete implementation or subclass to instantiate. |
| Public properties outside the constructor, inherited properties, and other unsupported class shapes | Limited | Declared scalar, array, backed enum, nested final class, and supported union public properties are hydrated after construction, including inherited properties, nullable and recursively nested self declarations, and classes without constructors. Public properties accept the same declarations as constructor fields and report the same errors. Supplied property values are validated and converted before the target constructor runs, then assigned after construction. Constructor-bound properties, including promoted readonly properties, are not assigned again. Omitted public properties keep their state after construction: initialized properties retain their values, while uninitialized properties remain uninitialized, including readonly properties. Targets with private or protected constructors are rejected with a dedicated error. |
mixed, untyped fields, object, stdClass, iterable, and callable |
Rejected | These declarations cannot preserve every value's original PHP type and JSON object/array shape, so they return a dedicated DecodeError whether or not the member is present. Use JsonValue for any JSON value, ArrayObject<string, T> rather than stdClass for a typed JSON object map, and a PHPDoc collection declaration on array rather than iterable. |
JsonValue |
Supported in fields, list items, map values, and tuple positions | Takes any JSON value. It cannot join a union, except as a JsonValue|null field, whose class must implement JsonSerializable. It is unsupported at the root and as a JsonType item. See Free-form JSON values. |
| Root arrays and maps | Limited | JsonType::array() returns lists and JsonType::map() returns ArrayObject<string, T> maps, preserving empty JSON arrays and objects. Values may be final classes, backed enums, or recursively nested array and map descriptors. Map keys must remain PHP strings. |
| Backed enums at the root | Supported | Json::decode('"ready"', Status::class) returns Status::Ready. The JSON root must be a backing value of the enum's backing type. Another JSON type returns an UnexpectedRootValue error, and a value without a case returns an InvalidValue error. Non-backed enums and enums that implement JsonSerializable are rejected, as in fields. |
Root scalars and null |
Unsupported | There are no descriptors for standalone values of these types. Use a transport class with a typed field. |
Classes implementing JsonSerializable |
Opt-in through #[Field], #[OtherMembers] or a JsonValue|null property |
At least one annotated public instance property, or one public instance property declared as JsonValue|null, is required. Use MappedJsonFields or supply a serializer that emits the declared field names and values, leaves out JsonValue|null properties that are null, and writes the other members next to the declared fields. Other serializers remain rejected. |
The round-trip contract also has these representation limits:
- The source object must be encodable by PHP's
json_encode(). Resources, non-finite floats, invalid UTF-8, and circular references are outside the supported input domain. - JSON does not retain PHP class identity. It can choose between multiple possible classes only through a discriminator member that every class declares; see Discriminated unions.
- A backed enum case and its backing scalar encode to the same JSON value, so a union containing both cannot recover the original PHP type.
- Collection declarations enforce both item types and JSON shape. Lists require
JSON arrays; maps require JSON objects. Scalar and enum items are validated
without coercion, except that integer JSON values are restored as floats when
declared as
float. Final-class items require JSON objects and are validated recursively. The same rules apply to constructor fields and public properties. Native arrays without a supported collection declaration are rejected. - A PHPDoc type must be compatible with the field's native type, such as
positive-intonintorlist<Foo>onarray. The decoder does not check this, because static analysis tools such as PHPStan and Psalm already report incompatible declarations. If the PHPDoc type contradicts the native type, for examplelist<Foo>on anintfield, the decoding result is unspecified. - JSON object members without matching constructor parameters or supported
public properties are ignored and do not appear when the decoded object is
re-encoded, unless the class collects them in a property marked with
#[OtherMembers]. - A missing required constructor-bound member produces an
InvalidValueerror. A missing optional member uses its constructor default. A presentnullis passed to the constructor and therefore overrides a nullable non-null default. An omitted public property outside the constructor keeps its initialized or uninitialized state. Re-encoding may include a default-valued public property whose member was absent from the input. Exact preservation of omissions is outside the object-to-JSON-to-object round-trip contract.
PHPDoc literal types constrain values exactly: 'foo'|42 accepts the string
foo and the integer 42, and rejects other strings, integers, and "42".
Use constructor @param tags or public-property @var tags alongside compatible
native scalar declarations. Supported literals are single- or double-quoted
strings, plain decimal numbers, true, and false. Numbers may have a sign and
digit separators, and floats may use exponent notation, such as -1_000, 3.0,
and -1.5e2. Hexadecimal, binary, and octal numbers are rejected; see
Number literals.
Unions may also contain scalar types and null.
final readonly class Result
{
/** @param 'foo'|42 $value */
public function __construct(public string|int $value) {}
}The PHPDoc type must name the same types as the native declaration, where each
literal stands for its type: 'foo'|42 names string|int. If it leaves out a
native type or adds one, the native declaration applies instead. For example,
@param 'foo'|'bar' $value on ?string $value leaves out null, so the field
accepts any string or null. Write 'foo'|'bar'|null to restrict it.
The same literals work in collection declarations, including list<'foo'|42>,
ArrayObject<string, true>, and array{'foo', 42, false}. String delimiters
inside quotes are literal characters; escape the matching quote or a backslash
with a backslash. Native true and false fields remain supported.
Double-quoted strings also recognize escape sequences, including hexadecimal,
octal, and Unicode escapes, the way PHPStan reads them: unlike PHP, \$ keeps
its backslash, and a Unicode escape is encoded as written, even if it is not a
valid code point. Strings do not interpolate variables and cannot span lines.
Constant expressions such as Status::ACTIVE, self::VALUE, parent::VALUE,
ClassName::class, global constants, and class-constant wildcards such as
self::STATUS_* resolve to their scalar values or backed enum cases. Class names
follow the declaring class's namespace and imports; constant imports are also
recognized. Constants containing arrays, objects other than backed enum cases,
or non-finite floats are rejected. Whole-valued float literals restore floats
from integer JSON values. Unions that cannot recover the original PHP type,
such as 1|1.0 or an enum case alongside its backing scalar, are rejected.
#[Field('json-name')] selects the JSON name of a declared public instance
property. It works with promoted or ordinary constructor-bound properties,
properties assigned after construction, inherited properties, and nested objects
or collections. Empty strings and integer-looking names such as "0" are allowed
for explicit field mappings; the restrictions on generic map keys are unchanged.
Names starting with a null byte are rejected because PHP would omit them when
encoding an object.
Constructor arguments and PHPDoc @param tags keep their PHP names. A mapped
field accepts only its JSON name. The ordinary PHP name is ignored unless it is
the JSON name of a different field. Swapped names are supported. Input conversion
errors use the JSON name in their paths, such as links[0].$ref; declaration
errors identify PHP declarations. Missing arguments keep the existing constructor
error behavior. Missing properties, defaults, and explicit nulls keep their usual
semantics.
Any #[Field] requires JsonSerializable, including a same-name annotation.
Applying the attribute to a private, protected, or static property, repeating it,
or assigning the same JSON name to two public properties returns a DecodeError.
A mapped property also cannot collide with an unannotated public property's name.
Mappings are checked when a class is resolved, including a directly declared
nested type whose field is omitted or whose collection is empty.
MappedJsonFields implements jsonSerialize() using the shared field metadata.
It returns an object containing initialized public instance properties, retaining
nulls and the same default-valued properties that native object encoding exposes.
Uninitialized properties are omitted, and so are JsonValue|null properties that
are null. The members that an #[OtherMembers] property holds come after the
declared fields. Private, protected, and static state is not
exposed. An empty result stays {} and numeric member names remain object keys.
Nested mapped objects are handled by PHP's normal JsonSerializable support.
The trait is optional. A handwritten serializer must emit an object with the
resolved names and unchanged property values. It must not drop required values,
rename them differently, or replace the object with a scalar or list. That is the
class author's round-trip responsibility; the decoder does not execute a
serializer to infer or verify its behavior. An interface or marker cannot prove
an arbitrary method's behavior. JsonSerializable classes without #[Field],
#[OtherMembers] or a JsonValue|null property, and custom-serialized enums,
remain outside this opt-in contract.
This feature does not restore Json::encode() or add docblock mapping syntax.
For the package's JSON Schema models, see JSON Schema.
For arbitrary JSON values, see Free-form JSON values.
To keep members that no field declares, such as unknown keywords, see
Other members.
A union of several final classes decodes when the JSON objects carry a member that names their class. Each class declares that member as an ordinary literal-typed field, so the classes need nothing specific to this library:
final readonly class Circle
{
/** @param 'circle' $type */
public function __construct(public float $radius, public string $type = 'circle') {}
}
final readonly class Rectangle
{
/** @param 'rectangle' $type */
public function __construct(public int $width, public int $height, public string $type = 'rectangle') {}
}
final readonly class Drawing
{
/** @param list<Circle|Rectangle> $shapes */
public function __construct(public array $shapes) {}
}Decoding {"shapes":[{"radius":2,"type":"circle"},{"width":3,"height":4,"type":"rectangle"}]}
into Drawing::class restores a Circle and a Rectangle. json_encode()
writes the member back, so the document round-trips, and PHPStan and Psalm
narrow $shape->type === 'circle' to Circle.
The decoder infers the discriminator from the classes. A JSON member name is a candidate when all of these hold:
- Every class in the union declares it, as a constructor field or a public
instance property. With
#[Field], the JSON name counts. - Its type in every class is one or more string literals, int literals, or
backed enum cases, such as
'circle',1,'square'|'box', orKind::Circlein PHPDoc on a field whose native type isKind. - No two classes share a JSON value. An enum case counts as its backing value,
and the integer
1and the string'1'are different values.
Without an attribute, the union needs exactly one candidate. A member that holds
the same literal in every class, such as a version of 1, is not a candidate,
so it does not get in the way. A whole enum, true, false, floats, and null
are never discriminator types.
When more than one member is a candidate, name the JSON member with
#[Discriminator] on the field that holds the union:
use Eventjet\Json\Discriminator;
final readonly class EventLog
{
/** @param list<Created|Deleted> $events */
public function __construct(#[Discriminator('event')] public array $events) {}
}The attribute works on promoted and ordinary constructor parameters and on
public properties. It applies to every union of several classes in the
declaration, including nested ones such as list<list<Circle|Rectangle>>.
The decoder still works out which value selects which class. The named member
must meet the candidate rules above. A name that starts with a null byte throws
an InvalidArgumentException, as with #[Field].
Discriminated unions work wherever one final class does: constructor fields,
public properties, list items, tuple positions, and map values, including
recursive structures such as an AndFilter that holds
list<AndFilter|OrFilter|Equals>. The classes take the union's one JSON-object
member, so they may share a union with null, scalars, backed enums, and one
list or tuple, but not with a map. Each class must meet the usual rules for a
final class. Only the members of a class are read for inference: if one of the
classes does not exist, the union is rejected as ambiguous.
Each decode reads the discriminator member, chooses the class, and decodes the
object with that class, including the discriminator field. The member is
required even when the field has a default. These are InvalidValue errors
that list the allowed values, such as Field shapes[1].type must be one of 'circle', 'rectangle'; 'triangle' given.:
- The object has no discriminator member.
- The discriminator has a different JSON type, such as
nullor a float. - The discriminator matches no class.
Errors inside the chosen class keep their usual messages and paths, such as
shapes[1].width. These declarations return UnsupportedDeclaration, from
both Json::decode() and Json::validateType():
- No member is a candidate.
- Several members are candidates and no attribute names one.
- The member that
#[Discriminator]names is not a candidate, for example because it is misspelled, not a literal, or shares a value between classes. #[Discriminator]is on a declaration without a union of several classes.
ADR 0005 explains why the decoder infers the discriminator instead of matching class fields or using an explicit mapping.
Integer fields support values from PHP_INT_MIN through PHP_INT_MAX. PHP
decodes JSON integers outside that platform range as floats, so they are
rejected instead of being rounded or coerced back to integers.
Float fields use PHP's native floating-point precision. The default
json_encode() and json_decode() behavior preserves finite values, including
subnormal values and precision-sensitive decimals. JSON does not distinguish a
whole-valued float such as 3.0 from the integer 3; the declared field type
restores the value to a PHP float during construction.
Decoding keeps JSON objects and arrays distinct, including empty values. An
empty concrete object round-trips as {}, an empty list<T> as [], and an
empty ArrayObject<string, T> as {}. This also applies to empty concrete
objects inside lists and maps. A JSON array cannot stand in for a concrete
object or map, and a JSON object cannot stand in for a list, even when its
member names are sequential integers such as "0" and "1".
Maps preserve numeric-looking member names that remain PHP string keys, such
as "01" and "1e0". Maps with string keys reject names that PHP converts to
integer keys, such as "0" and "42"; ArrayObject<array-key, T> maps accept
them as integer keys. For concrete class targets, numeric-looking names are
unknown fields and follow the same policy as other unknown members: they are
ignored, or collected as other members.
These rules also apply to root collections declared with JsonType::array()
or JsonType::map(), whose maps have string keys.
Eventjet\Json\JsonValue holds any JSON value: null, a Boolean, a number, a
string, an array, or an object, nested in any combination. Use it for members
whose shape the format leaves open, such as JSON Schema's const and
default. Plain PHP can't declare such a type: mixed allows PHP values that
don't round-trip, and PHPStan and Psalm reject a recursive type alias. So, like
#[Field], JsonValue is a class from this package that your classes import.
value() returns the value one level at a time. A JSON array is a
list<JsonValue>, a JSON object is an ArrayObject<array-key, JsonValue>, and
other values are null, bool, int, float, or string. Member names that
PHP turns into integer keys, such as "42", stay integer keys in the
ArrayObject; json_encode() still writes it as an object. Changing a list or
an ArrayObject that value() returned doesn't change the JsonValue.
json_encode() writes a JsonValue as the JSON value that it holds.
A JsonValue stores what a json_encode()/json_decode() round trip of its
input gives. Associative arrays, stdClass, and ArrayObject become objects,
lists become arrays, backed enums become their backing values, and
JsonSerializable objects become what their jsonSerialize() returns. Numbers
follow json_encode(): whole floats that it writes without an exponent become
integers, so 1.0 becomes 1 and -0.0 becomes 0. Other floats stay
floats. So Json::decode(json_encode($object), $class) gives back identical
values, PHP types included. JSON Schema treats 1.0 and 1 as equal anyway.
The constructor throws an InvalidArgumentException for input that isn't
JSON-shaped: objects of other classes, such as closures, resources, non-backed
enums, INF and NAN, strings or member names that aren't valid UTF-8, member
names that start with a null byte, and input nested more than 512 levels deep.
Each jsonSerialize() call counts as a level, and so do the levels inside
JsonValue objects in the input. If a jsonSerialize() method throws, the
constructor throws an InvalidArgumentException with that exception as its
previous one.
The decoder wraps the JSON value of a JsonValue field, list item, map value,
or tuple position, including null. A JSON number too large for a PHP float,
such as 1e400, returns an InvalidValue error. JsonValue takes every JSON
value, so it cannot share a union with another type, null included, and a
union like JsonValue|string returns an UnsupportedDeclaration error.
The exception is a field declared as JsonValue|null, which keeps an absent
member apart from JSON null. PHP null means the member was absent, and
new JsonValue(null) is a present JSON null. json_encode() would write an
absent value as null, so a class with a public JsonValue|null property must
implement JsonSerializable and leave out the member when the property is
null. MappedJsonFields does that. Without JsonSerializable,
Json::decode() and Json::validateType() return an UnsupportedDeclaration
error. This requirement follows the type that the decoder reads for the field,
which is the constructor parameter's type when a constructor parameter sets the
property. Such a property also opts the class into JsonSerializable, as
#[Field] does. For that, only the declared type of the property counts, not
that of a constructor parameter.
JsonValue is unsupported as the root type and as a JsonType item. It has no
equals() method, because == treats values such as "1" and 1 as equal.
To compare two values, compare their json_encode() output.
The decoder ignores members that no field declares, unless the class collects
them. Mark one public property with #[OtherMembers] to collect them, for
example to keep vendor extensions or the unknown keywords of a JSON Schema:
use Eventjet\Json\Field;
use Eventjet\Json\JsonValue;
use Eventjet\Json\MappedJsonFields;
use Eventjet\Json\OtherMembers;
final readonly class Schema implements JsonSerializable
{
use MappedJsonFields;
/** @param array<array-key, JsonValue> $others */
public function __construct(
#[Field('$id')]
public string|null $id = null,
public string|null $type = null,
#[OtherMembers]
public array $others = [],
) {}
}Decoding {"type":"string","x-label":"Name","id":"legacy"} gives $others
with the members x-label and id, each as a JsonValue.
- A member is collected when its name matches no declared field's JSON name.
With
#[Field], only the JSON name counts, so a legacy"id"next to#[Field('$id')] $idis collected, not bound to$id. The name of the collecting property doesn't count either: a member named"others"is collected too. Every unknown member is collected, not onlyx-members. - The property holds the members in document order, keyed by name. Names that
PHP turns into integer keys, such as
"42", become integer keys, andjson_encode()writes them back as"42". If there are no unknown members, the decoder passes an empty array, even to a property without a default value. - Declare the property as a public instance property, promoted or not, with the
native type
arrayand the PHPDoc typearray<array-key, JsonValue>.array<int|string, JsonValue>andarray<JsonValue>work too.null, other key or value types, and a variadic constructor parameter are rejected. - A class can mark only one property, and that property can't also use
#[Field]. The class must implementJsonSerializable, becausejson_encode()would write the property as one nested member.Json::decode()andJson::validateType()return anUnsupportedDeclarationerror for each of these. MappedJsonFieldswrites the collected members after the declared fields. A round trip can therefore change the order of the members, but not the document's meaning. A handwrittenjsonSerialize()must write them next to the declared fields as well.
Decoded objects never hold a collected member that has the JSON name of a
declared field. An object built by hand can, but then the round trip isn't
guaranteed: decoding would bind the member to the declared field. Constructors
don't check this. MappedJsonFields throws an UnexpectedValueException when
it encodes such an object, also when it leaves out the declared field, for
example a JsonValue|null property that is null.
ADR 0006
records the measurements behind this check.
The Eventjet\Json\Schema namespace has a class for each supported draft of
JSON Schema: Draft4, Draft6, Draft7, Draft201909, and Draft202012.
They are final readonly classes with one property per keyword of the draft, in
its constructor. Type is a backed enum for the values of the type keyword.
The classes follow the same rules as your own classes, so Json::decode(),
Json::validateType(), and json_encode() work with them as with any other
class.
nullmeans absent. Apart fromdefaultandconst, no keyword takesnullas its value, so a property that isnullstands for a keyword the schema doesn't have.defaultandconstareJsonValue|null. PHPnullmeans the keyword is absent, andnew JsonValue(null)means it is present with the value JSONnull. The items ofenumandexamplesareJsonValueobjects.- Nested schemas have the class of the draft. From Draft 6 on, they can
also be
trueorfalse. In Draft 4, onlyadditionalItemsandadditionalPropertiestake Booleans. - Maps such as
properties,patternProperties,$defs, anddependentSchemasareArrayObject<array-key, …>maps, so empty objects stay{}and member names such as"200"work. PHP turns such names into integer keys. - Numbers are
int|float. A JSON integer becomes anint, and a JSON number with a fraction or an exponent becomes afloat.json_encode()writes a whole float such as1.0as1, which decodes as anint. JSON Schema treats both as the same number. - Names that start with
$, such as$ref,$id, and$schema, are mapped with#[Field]. The PHP property leaves out the$, as in$schema->ref. In Draft 4, the identifier keyword isid, without$. - Encoding:
jsonSerialize()writes the keywords that aren'tnull, under their JSON names, followed by the unknown keywords.
The classes parse schemas; they don't validate them. A JSON null decodes like
an absent keyword, so {"type": null} decodes, and json_encode() writes it
back without type. Constraints such as non-negative lengths aren't checked.
The classes don't validate instances against a schema or resolve $ref.
Unknown keywords, such as x-* extensions or keywords of another draft, are
collected in $unknownKeywords with #[OtherMembers], each
as a JsonValue, and json_encode() writes them back after the keywords of
the draft. So a schema keeps everything it has through a round trip, apart from
the order of its members. Keywords that a draft replaced count as unknown:
Draft201909 has definitions but not dependencies, and Draft202012 has
neither, nor $recursiveRef and $recursiveAnchor. Their values are
JsonValue objects, not schema objects.
json_encode() rejects an unknown keyword with the name of a keyword of the
draft, such as new Draft202012(unknownKeywords: ['$ref' => $value]), as
described in Other members.
A schema that is just true or false returns an UnexpectedRootValue
error, because the classes describe JSON objects. To accept Boolean schemas at
the root, check for them before decoding:
$data = json_decode($json);
$schema = is_bool($data) ? $data : Json::decode($json, Draft202012::class);To change a schema, call with(). It takes the same named arguments as the
constructor and returns a copy with new values for the keywords you pass. The
other keywords keep their values, and null removes a keyword:
$person = $schema->with(title: 'Person', description: null);Each parameter of with() has the default Keep::Current, which you can also
pass yourself, as in $schema->with(title: $title ?? Keep::Current). The copy
shares nested schemas and ArrayObject maps with the original. readonly
doesn't stop changes to a map in place, so $copy->properties['name'] = …
changes the original too.
ADR 0007 explains
why with() takes named arguments.
When you build schemas in PHP, static analysis may infer a narrower map type
than a parameter takes. For example, PHPStan reads new ArrayObject(['name' => new Draft202012()]) as ArrayObject<string, Draft202012> and rejects it for
properties, because the key and value types of ArrayObject are invariant.
Declare the full map type with a @var tag:
/** @var ArrayObject<array-key, Draft202012|bool> $properties */
$properties = new ArrayObject(['name' => new Draft202012(type: Type::String)]);
$schema = new Draft202012(type: Type::Object, properties: $properties);Use list<T> for JSON arrays. Lists may be empty and preserve the [] shape.
Use non-empty-list<T> when the JSON array must contain at least one item.
It supports the same item types as list<T> and rejects an empty JSON array.
Use a positional tuple such as array{int, string} for a JSON array with a
fixed number of items. Each position has its own type and all positions are
required. The decoder rejects missing or extra items, JSON objects, and values
that do not match their position's type. array{} accepts only [].
Tuple positions support the same scalar, backed-enum, final-class, and nested
collection types as list items, including the recognized string and integer
refinements below.
Explicit numeric positions are also supported, such as
array{0: Person, 1: string, 2?: int}. Positions must be consecutive and start
at zero. Optional positions must follow all required positions; values may
omit only a trailing suffix. The example accepts two or three items. An
all-optional tuple such as array{0?: int, 1?: string} also accepts [].
Omitted positions stay absent, while a present null must match the declared
item type and is rejected for these non-nullable types.
Declarations with gaps, duplicate or out-of-order indexes, or required
positions after optional ones return a DecodeError, even when the field is
omitted. JSON objects with numeric member names cannot stand in for tuples.
These rules preserve JSON array shape. Named keys and open shapes are not yet supported.
Tuple positions may use the supported unions described below.
Declare tuples with @param on constructor fields or @var on public
properties, just like other collections:
final readonly class Label
{
/** @param array{int, string} $value */
public function __construct(public array $value) {}
}Decoding {"value":[42,"answer"]} into Label::class restores the tuple.
{"value":["42","answer"]}, {"value":[42]}, and
{"value":[42,"answer",true]} each return a DecodeError. Value errors name
the failing position, such as value[0]; length errors report the expected
count or allowed range and the actual number of items.
Use non-empty-array<string, T> for a JSON object that must contain at least
one member. The string-key and nonempty constraints ensure that PHP's
json_encode() represents every supported value as an object. Decoding rejects
JSON arrays and empty JSON objects for these fields.
non-empty-map<string, T> is an alias for non-empty-array<string, T>.
It supports the same values, including supported unions, and uses the same
string-key and nonempty validation for constructor fields and public properties.
Runtime errors use the normalized non-empty-array name. Other key types
remain unsupported.
Use ArrayObject<string, T> for a JSON object that may be empty. ArrayObject
is a standard PHP container whose empty value encodes as {}. The decoder
treats it as a map container rather than as an ordinary non-final class and
always constructs an exact ArrayObject; subclasses are outside the supported
round-trip domain.
Use ArrayObject<array-key, T>, or its equivalent ArrayObject<int|string, T>,
for a JSON object whose member names are free-form, such as the HTTP status
codes in an OpenAPI responses object. It accepts every member name. As in any
PHP array, a name such as "200" becomes the integer key 200, while names such
as "01" and "-0" stay strings. The round trip stays exact because
json_encode() always writes an ArrayObject as a JSON object:
new ArrayObject([200 => 'OK']) encodes as {"200":"OK"}. non-empty-array
maps keep string keys, because json_encode([0 => 'x']) writes the list
["x"]. ArrayObject<int, T> is unsupported.
Declare constructor collections with @param on the constructor. This complete
example uses all four collection forms and restores map values as Product
objects:
<?php
declare(strict_types=1);
namespace Example;
use ArrayObject;
use Eventjet\Json\DecodeError;
use Eventjet\Json\Json;
final readonly class Product
{
public function __construct(public string $name) {}
}
final readonly class Catalog
{
/**
* @param list<string> $tags
* @param non-empty-list<int> $quantities
* @param non-empty-array<string, Product> $featured
* @param ArrayObject<string, Product> $products
*/
public function __construct(
public array $tags,
public array $quantities,
public array $featured,
public ArrayObject $products,
) {}
}
$catalog = Json::decode(
'{"tags":[],"quantities":[1,2],"featured":{"primary":{"name":"Notebook"}},"products":{}}',
Catalog::class,
);
if ($catalog instanceof DecodeError) {
throw $catalog;
}
echo $catalog->featured['primary']->name;The example prints Notebook. Re-encoding $catalog preserves tags as []
and products as {}. Changing quantities to [], featured to {}, or
either map to a JSON array returns a DecodeError.
For public properties outside the constructor, put @var on the property:
final class Labels
{
/** @var list<string> */
public array $values = [];
}Decoding {"values":["new","sale"]} into Labels::class hydrates the list.
An omitted values member leaves its default [] unchanged. An item of the
wrong type, such as {"values":[42]}, returns a DecodeError.
Every array or ArrayObject field needs a supported declaration: @param on
the constructor for a constructor-bound field, or @var on a public property
outside the constructor. Missing or unrecognized declarations return a
DecodeError naming the field and the supported collection forms, even when
the JSON member is omitted. The decoder does not fall back to an unvalidated
array. This includes malformed PHPDoc, unknown type names, and types whose
conversion is not supported, such as named or open array shapes and unions
that combine multiple members with the same JSON shape. Existing dedicated errors for ambiguous map declarations
and unsupported class types still apply.
Collection items and values may use unions, such as list<string|int|Person>,
ArrayObject<string, Status|null>, or array{int|string, Person|null}.
Each union may contain scalar types, true, false, null, one final class
or a discriminated union of several final classes,
and backed enums with unambiguous case values. The same class and enum
restrictions as ordinary object fields apply, even when the collection is
empty or the field is omitted. Enum backing scalars and overlapping enum
values remain ambiguous and are rejected. Int-backed enums also cannot join a union with float: whole-valued floats encode as JSON integers and can collide with enum backing values. Union order does not affect selection.
For scalar-only unions, an integer remains an integer when int is present;
a float member restores whole-valued floats when int is absent. Explicit null is accepted only when
null is a member. Optional tuple positions can still be omitted independently.
A union may also contain types whose values cannot be encoded: non-backed
enums without custom serialization, and PHPDoc resource, open-resource,
or closed-resource members. At least one member must be supported for
decoding. For example, NonBackedEnum|string decodes strings,
?NonBackedEnum accepts only null, and list<resource|int> decodes integer
items. Declarations containing only non-encodable alternatives are rejected,
even when the field is omitted or its collection is empty. The existing
ambiguity rules still apply to the supported members. Encodable but unsupported
types, such as interfaces, abstract classes, and enums implementing
JsonSerializable, do not qualify for this exception. Encoding remains the
caller's json_encode() responsibility; float remains supported even though
INF and NAN cannot be encoded.
Lists, tuples, and maps may nest recursively in any combination, using the
same supported item and value types at every level. For example, list<list<int|null>>,
non-empty-array<string, list<Person>>, and
ArrayObject<string, ArrayObject<string, Status|null>> are supported.
Each level retains its shape rules: lists encode as arrays, nonempty maps
encode as objects and reject {}, and ArrayObject maps preserve {}.
A nested collection may also be nullable, such as list<list<int>|null>;
null and an empty list remain distinct. These declarations work on constructor
fields and public properties, and errors include the complete path through
all collection levels. Class and enum restrictions are validated even inside
empty collections or when the field is omitted.
Collections may join unions with scalar, enum, class, and null members.
This works for constructor fields, public properties, tuple positions, list
items, and map values. Selection uses JSON shape: an array selects a list or tuple, an
object selects a map or final class, and a scalar follows the existing union
rules. For example, list<Person>|Person|null restores either a list of
people, one person, or null. Nested declarations such as
list<list<Person>|Person|null> use the same selection rules.
For a field union, PHPDoc must describe every native union member, replacing
array and ArrayObject with their supported collection declarations:
final readonly class People
{
/** @param list<Person>|Person|null $value */
public function __construct(public array|Person|null $value) {}
}Use @var in the same way on a public property outside the constructor.
Nullable collection fields, such as native ?array with PHPDoc
list<int>|null, are supported. PHPDoc member order does not affect selection,
and repeated members with the same normalized declaration are ignored.
PHPDoc cannot add or omit a
native union member. Existing scalar refinements normalize to their underlying
type; they do not add runtime constraints.
Each union may have at most one JSON-array member and one JSON-object member.
list<int>|ArrayObject<string, int> preserves both [] and {}.
list<int>|non-empty-array<string, int> also works, with an empty object
rejected by the map's nonempty constraint. list<int>|list<string> and
list<int>|array{int} are rejected because JSON arrays do not identify which
member to restore. A map alongside a class, a discriminated union of classes, or
another map is rejected for the same reason. Unsupported or ambiguous declarations are rejected even when
the collection is empty or the field is omitted.
Tuples may contain lists, maps, and other tuples, and may appear inside list
items, map values, or supported unions. For example,
list<array{Person, ArrayObject<string, list<int>>}> describes a list of
pairs: each pair contains a person and a map of integer lists.
array{0: list<Person>, 1?: ArrayObject<string, array{int, string}>}
also supports an omitted trailing map position. A present empty map remains
{}, an empty list remains [], and omitted tuple positions stay absent.
Nullable collection positions distinguish null from an omitted position.
The existing 64-type-level declaration limit applies to mixed nesting too.
Generic array<string, T> maps remain unsupported at every level because
their empty value cannot preserve JSON object shape.
Collection types may span multiple PHPDoc lines, including lines with the
usual leading * and either LF or CRLF line endings:
/**
* @param array{
* 0: int,
* 1?: string
* } $value
*/The type must start on the line of its tag, and a @param variable must follow
on the line where the type ends. Inside <…>, {…}, and (…), line breaks may
appear anywhere. Outside them, a union may continue on the next line only if
that line starts with |.
Parsing a declaration does not enable its decoding: the supported collection forms and item types above still apply. Declarations are limited to 64 type levels, counting the outer collection and its innermost item through generic arguments and shape entries. Unions and parentheses do not add a level.
These PHPDoc declarations describe fields inside a root object. For root
collections, use JsonType::array() or JsonType::map(); PHPDoc strings are
not accepted as targets.
The currently supported T declarations are string, int, float, bool,
a backed enum, a final class, or a supported union as described above.
Whole-valued integers are restored as floats
when T is float. The same declarations work on public properties outside the
constructor. Collection items and values may use self to refer to the class
that declares the field, such as list<self> or ArrayObject<string, self>.
That class must be final, including when the declaration is inherited.
Collection items and values may also use non-empty-string, numeric-string,
or literal-string, such as list<non-empty-string> or ArrayObject<string, numeric-string>.
These declarations validate values as strings without coercion and preserve the
collection's shape rules. The narrower nonempty, numeric, and literal-string
constraints are not validated at runtime. Errors report the underlying string
type.
Integer-range items and values, such as list<int<5, max>>,
non-empty-array<string, int<min, -1>>, and ArrayObject<string, int<-5, 5>>,
are also recognized. Bounds may be plain decimal integers without a plus sign,
min for the lower bound, or max for the upper bound. These declarations validate values as integers
without coercion and preserve collection shape rules. Range bounds are not
enforced at runtime. Errors report the underlying int type.
The integer refinements positive-int, negative-int, non-negative-int, and
non-positive-int are recognized in collection items and values as well. They
validate the underlying int type without coercion and retain the same
collection shape rules. Their sign constraints are not enforced at runtime.
These names follow PHPStan's integer range declarations.
non-zero-int is also recognized as int in list items, tuple positions,
map values, and supported unions. It validates the integer type without
coercion; the nonzero constraint is not enforced, so zero is accepted.
Errors report the underlying int type.
Map member names must remain PHP string keys. Integer names such as "0",
"-1", and "42" are rejected because PHP converts them to integer array keys,
even when the keys are non-sequential. Numeric-looking names that remain
strings, such as "01", "+1", "1.0", and "1e0", are supported and preserved.
This rule applies to both map forms in constructor fields and public properties.
Class and enum names in collection PHPDoc follow the namespace and class
imports of the class that declares the field. This includes ordinary use
imports, aliases, grouped imports, namespace aliases such as Models\Person,
fully qualified names such as \Example\Person, and namespace-relative names
such as namespace\Person. Names in a global-namespace class resolve directly
in the global namespace. Aliases are case-insensitive. Function and constant
imports do not supply class names, and unknown names still return a
DecodeError, including when the field is omitted. Inherited fields use their
declaring class's imports. An alias for the ArrayObject container itself is
also supported.
Import resolution reads the declaring class's PHP source file. Keep that source available and consistent with the loaded class when using imported PHPDoc names. Fully qualified names do not need source-file access.
Plain array<TKey, TValue> declarations are rejected with a DecodeError;
choose one of the two map declarations above to state whether the map may be
empty.
The decoder reads doc comments with PHPStan's PHPDoc syntax: the grammar of phpstan/phpdoc-parser 2.x as PHPStan 2.x configures it, without runtime dependencies. It deliberately differs in two ways:
- Number literals that PHP and PHPStan read differently are rejected. See Number literals.
non-empty-map<string, T>is an alias fornon-empty-array<string, T>.
The parity test suite compares this with phpdoc-parser itself. See
PHPDoc parity.
A constructor parameter takes its type from the constructor's doc comment.
@phpstan-param overrides @psalm-param, which overrides @phan-param, which
overrides @param. Among tags of the same kind, the last one for a parameter
counts.
/**
* @param array $value Shown in the documentation.
* @phpstan-param list<int> $value
*/A public property outside the constructor takes its type from its own doc
comment, with @phpstan-var, @psalm-var, @phan-var, and @var in the same
order. Tags without a variable name override named ones, but if the kind that
counts has more than one of them, none counts. A named tag counts only for the
property it names.
Tags count only at the start of a line, and the selected tag counts even if the
decoder does not support its type. Unlike PHPStan, the decoder does not skip
@psalm- tags whose types PHPStan cannot resolve, does not read @var tags on
promoted constructor properties, and does not inherit doc comments from parent
classes or interfaces.
PHPStan reads some number literals differently from PHP: it reads 0x10 as 0
and 052 as 52, and it caps integers beyond PHP's range. So a declaration with
any of these is unsupported, and the error asks for a plain decimal number:
- hexadecimal, binary, and octal integers, such as
0x2a,0b101,0o52, and052 - integers beyond PHP's integer range
- floats that do not fit, such as
1e999 - integer range bounds that are not plain decimal integers or that have a plus
sign, such as
int<0x10, max>andint<+5, max>
Signs, digit separators, decimal points, and exponents work: -42, +7,
1_000, .5, and 1.5e3.
The decoder recognizes the keywords of the types it can decode: int, float,
bool, string, true, false, null, array-key (int|string), list,
non-empty-list, array{…}, non-empty-array, non-empty-map, self,
integer ranges such as int<0, max>, and the refinements listed under
Collection declarations. resource,
open-resource, and closed-resource may appear in unions.
array-key works only as a value. Map keys must be string, so
non-empty-array<array-key, T> is unsupported.
Keywords are case-sensitive, and synonyms are not keywords: Int, List, and
Self are read as class names, and integer, boolean, and double do not
mean int, bool, and float. static, parent, number, and scalar are
not recognized either and count as unknown types.
A PHPDoc type that names something the decoder cannot resolve returns a
DecodeError on every field, even when the JSON member is missing. This
includes unknown class names, type aliases such as @phpstan-type, keywords the
decoder does not support, such as scalar or lowercase-string, and forms it
cannot read, such as intersections. @param 'a'|Missing $s and
@param list<int|Missing> $l are both rejected.
Static analysis accepts some of these types, such as type aliases, and trusts them. If the decoder ignored them, it would accept values that the declared type does not allow. Write the type out, for example as a literal union or a backed enum, or remove the PHPDoc type so that the native type applies.
Every DecodeError has a kind. Use the kind to tell errors apart. Messages
are meant for people.
InvalidJson:json_decode()could not parse the input.UnexpectedRootValue: the JSON root does not have the type that the target needs: an object for a class, an array or object for aJsonType, or a backing value for a backed enum.InvalidValue: the JSON does not match the declarations. This includes values of the wrong type, unknown enum cases, values outside a literal type, an empty array fornon-empty-list<T>, an empty object fornon-empty-array<string, T>, member names that PHP converts to integer keys in a map withstringkeys, tuples of the wrong length, and missing required constructor members. For a missing member, the message is PHP's own message about the constructor call. If several members are invalid, the error names the first field in declaration order: constructor parameters first, then public properties outside the constructor.UnsupportedDeclaration: the class cannot be decoded, whatever the input. This includes everything under Explicitly unsupported, invalid#[Field]annotations, classes that do not exist, and failures while reading declarations, such as an attribute that throws. Two cases only show up when the input reaches them: a field whose class does not exist, once the JSON gives it a value other thannull, and a public readonly property that the constructor already set, once the JSON contains it.json_encode()writes that property, so decoding its output always fails.Json::validateType()returns only errors of this kind.ConstructorFailure: the target's constructor, or asethook of one of its public properties, threw.getPrevious()returns that exception.
Apart from the first two kinds, messages start with Could not create X from the JSON object:, where X is the class whose field could not be decoded or
whose constructor failed. Errors inside a nested object name
the nested class, and errors in a root JsonType descriptor name its item
class. Value errors name the path from the JSON root, such as links[0].$ref;
declaration errors name the PHP field.
These exclusions keep decoding unambiguous and preserve PHP types and JSON shapes.
| Feature or value | Why it is unsupported |
|---|---|
| Unions containing multiple enums with overlapping backing values | The same JSON scalar would identify cases from more than one enum, so decoding could not recover the original enum type. |
Unions combining a backed enum with its backing scalar type, such as MyStringBackedEnum|string or MyIntBackedEnum|int |
An enum case and its backing scalar encode to the same JSON value. Checking the enum first would turn an original scalar into an enum case; checking the scalar first would lose the enum case. JSON cannot recover the original PHP type, so these unions break the round-trip contract. |
| Interface and abstract class type hints | They cannot be instantiated directly, and JSON does not identify which concrete implementation or subclass to create. Resolving one would require additional selection rules or metadata. |
| Non-final class type hints in object fields | A field may contain a subclass of its declared type, but JSON does not identify that runtime class. Reconstructing the declared class could silently change the value. Root targets are unaffected because the caller supplies their exact class. |
| Intersection type hints | JSON does not identify the concrete class that satisfies every member of an intersection. Resolving one would require additional selection rules or metadata. |
| Non-backed enum values | Without custom serialization, they have no JSON representation. Their type may appear in a union with a supported member, including null; only supported members can be decoded. Enums implementing JsonSerializable remain unsupported because their custom representation cannot be recovered generically. |
Unannotated object targets implementing JsonSerializable |
Arbitrary custom representations cannot be inferred. Annotated targets opt into the field-mapping contract described above. |
| Variadic constructors | JSON members bind to individual named constructor arguments. Reconstructing a variadic argument list would require separate unpacking and key-binding rules. |
| Constructor parameters without same-named declared public instance properties | The class shape does not provide a stable JSON member from which decoding can recover those argument values. |
Non-JSON-encodable values, including resources, INF, NAN, invalid UTF-8, and circular references |
JSON cannot represent these values without losing information or changing their meaning. They fall outside the round-trip contract. |
| JSON objects with duplicate member names | They cannot be produced by json_encode() from supported PHP values. PHP's decoder does not report them separately, and detecting them would require reparsing every document. Callers must not rely on which duplicate value is retained. |
| Implicit coercion of mismatched scalar types | Converting values such as "42" to 42 hides a type mismatch and changes the value's type. Decoding must validate the declared type. |
| Automatic naming strategies and multiple aliases for one property | Only explicit #[Field] mappings are supported. Each property has one accepted JSON name. |
mixed, untyped, object, stdClass, iterable, and callable fields |
These declarations do not provide enough type information to restore every value's original PHP type and JSON shape. Use JsonValue for any JSON value. In particular, object omits the concrete class, while mixed, untyped fields, and stdClass members can contain PHP arrays that encode as JSON objects. iterable does not declare its items, or whether a JSON array holds a list or a map. A callable can be a closure, which has no JSON representation, and a JSON string or array does not show that it names a callable. |
Generic array<TKey, TValue> map declarations |
An empty PHP array encodes as [], so it cannot represent an empty JSON object. Depending on its runtime keys, a nonempty PHP array can also encode as either an array or an object. Use non-empty-array<string, TValue> for a map that cannot be empty or ArrayObject<string, TValue> or ArrayObject<array-key, TValue> for a map that may be empty. |