Decode JSON into typed PHP objects, including nested objects, backed enums, and
collections. Use native type declarations and PHPDoc to describe your data;
eventjet/json checks incoming values and constructs the objects for you.
The library pairs with PHP's json_encode(): supported PHP values can be
encoded and decoded without losing their types or JSON object/array shapes.
Ordinary data classes need no package-specific attributes or interfaces.
Optional field-name mapping uses #[Field] with PHP's JsonSerializable.
Requires PHP 8.4 or later, with the JSON and Tokenizer extensions. There are no other runtime dependencies.
composer require eventjet/jsonDefine a class whose public properties match the JSON member names, then pass
its class name to Json::decode():
<?php
declare(strict_types=1);
require 'vendor/autoload.php';
use Eventjet\Json\DecodeError;
use Eventjet\Json\Json;
enum Status: string
{
case Active = 'active';
case Inactive = 'inactive';
}
final readonly class Person
{
public function __construct(
public string $name,
public Status $status,
public int|null $age = null,
) {}
}
$person = Json::decode(
'{"name":"Ada","status":"active","age":36}',
Person::class,
);
if ($person instanceof DecodeError) {
throw $person;
}
echo $person->name; // Ada
echo $person->status->value; // active
$json = json_encode($person, JSON_THROW_ON_ERROR);PHPStan and Psalm infer Person|DecodeError for the result. After the error
check, you can use the object's typed properties directly.
Json::decode() returns failures as DecodeError values. You choose whether
to throw, log, or otherwise handle them. $error->kind tells you what went
wrong:
| Kind | What went wrong |
|---|---|
DecodeErrorKind::InvalidJson |
The input is not valid JSON. |
DecodeErrorKind::UnexpectedRootValue |
The JSON root does not have the type the target needs. |
DecodeErrorKind::InvalidValue |
A JSON value does not match its declared type, or a required member is missing. |
DecodeErrorKind::UnsupportedDeclaration |
The target class can't be decoded, whatever the input. |
DecodeErrorKind::ConstructorFailure |
The target's constructor, or a set hook of one of its public properties, threw. |
The first three kinds mean the input is wrong. An unsupported declaration is a
bug in your class: fix the class, and catch it early with
Json::validateType(). Whether a
constructor failure is the client's fault depends on your constructor. For
example, an HTTP API can answer bad input with 400 and everything else with 500:
use Eventjet\Json\DecodeErrorKind;
$person = Json::decode($requestBody, Person::class);
if ($person instanceof DecodeError) {
$status = match ($person->kind) {
DecodeErrorKind::InvalidJson,
DecodeErrorKind::UnexpectedRootValue,
DecodeErrorKind::InvalidValue => 400,
DecodeErrorKind::UnsupportedDeclaration,
DecodeErrorKind::ConstructorFailure => 500,
};
}From kind 3 on, messages name the class that could not be created; value errors
also name the path to the failing value, such as people[1].name. When PHP or
your code threw an exception, getPrevious() returns it. See
Errors for details.
Call Json::validateType() to check a class and the classes nested within it
without JSON input or object construction:
public function testJsonDeclarations(): void
{
$error = Json::validateType(Person::class);
self::assertNull($error, $error?->getMessage() ?? '');
}The result is null for supported declarations or the first DecodeError,
whose kind is always DecodeErrorKind::UnsupportedDeclaration. Validation
follows constructor parameters, public properties, unions, and PHPDoc
collection items, including nested lists, maps, and tuples. It checks optional
and nullable fields even when no values are supplied and safely handles
recursive class declarations. You can also pass a root collection descriptor,
such as JsonType::array(Person::class).
This checks the decoder's declaration rules. It does not run constructors, validate particular values, or prove that custom constructor behavior preserves values. Keep round-trip tests for those behaviors.
Declare nested objects with their final class type and collection items with
PHPDoc. This example uses the Person class above:
final readonly class Team
{
/** @param list<Person> $members */
public function __construct(
public Person $owner,
public array $members,
) {}
}
$team = Json::decode(
'{"owner":{"name":"Ada","status":"active"},"members":[{"name":"Grace","status":"active"}]}',
Team::class,
);
if ($team instanceof DecodeError) {
throw $team;
}
echo $team->members[0]->name; // GracePut collection declarations in constructor @param tags, or in @var tags
for public properties outside the constructor.
| JSON value | PHPDoc declaration | PHP value |
|---|---|---|
| Array, possibly empty | list<T> |
array |
| Array with at least one item | non-empty-list<T> |
array |
| Positional array | array{int, string} |
array with typed positions |
| Object, possibly empty | ArrayObject<string, T> |
ArrayObject |
| Object with any member names, possibly empty | ArrayObject<array-key, T> |
ArrayObject |
| Object with at least one member | non-empty-array<string, T> |
array with string keys |
Items and values can be scalars, backed enums, final classes, supported unions,
or nested collections. Empty lists stay []; empty ArrayObject maps stay
{} when encoded. Generic array<string, T> maps are unsupported because an
empty PHP array encodes as [].
See the type reference for nullable collections, optional tuple positions, unions, and PHPDoc name resolution.
PHPDoc literal types restrict a field to specific values. Strings, integers,
floats, true, and false work, and so do class constants, including
wildcards such as self::STATE_*:
final readonly class Task
{
public const string STATE_OPEN = 'open';
public const string STATE_DONE = 'done';
/**
* @param self::STATE_* $state
* @param 1|2|3 $priority
*/
public function __construct(
public string $state,
public int $priority,
) {}
}Decoding {"state":"closed","priority":1} returns a DecodeError, because
closed is not one of the declared values. Literals also work inside
collections, such as list<'red'|'green'>.
Prefer backed enums when you control the class. PHP itself enforces an enum
type everywhere the value is used, while a literal type is only a PHPDoc
comment: PHP still sees a plain string or int, and only decoding and static
analysis check the narrower values. Use literals for existing classes you can't
change to enums, or for one-off values that don't deserve an enum.
See the type reference for the supported syntax and limits.
Use JsonType::array() for a JSON array or JsonType::map() for a JSON object
whose member names are map keys:
use Eventjet\Json\JsonType;
$people = Json::decode(
'[{"name":"Ada","status":"active"}]',
JsonType::array(Person::class),
); // list<Person>|DecodeError
if ($people instanceof DecodeError) {
throw $people;
}
echo $people[0]->name; // Ada
$directory = Json::decode(
'{"author":{"name":"Ada","status":"active"}}',
JsonType::map(Person::class),
); // ArrayObject<string, Person>|DecodeError
if ($directory instanceof DecodeError) {
throw $directory;
}
echo $directory['author']->name; // AdaRoot descriptors accept final classes, backed enums, and nested array or map
descriptors, such as JsonType::map(JsonType::array(Person::class)). They do
not accept scalar, nullable, union, or tuple item declarations.
A backed enum can also be the root on its own. Json::decode('"ready"', Status::class) returns Status::Ready. Standalone scalar and null roots are
unsupported.
Maps with string keys, at the root or in a field, require keys that remain
PHP strings. For example, "01" is supported, but "42" is rejected because
PHP converts it to an integer key. Only fields declared as
ArrayObject<array-key, T> accept such names.
Use JsonValue for a field that can hold any JSON value, such as the
metadata of a webhook or the details of an API error:
use Eventjet\Json\JsonValue;
final readonly class ApiError
{
public function __construct(
public string $code,
public JsonValue $details,
) {}
}
$error = Json::decode('{"code":"invalid","details":{"field":"email","limits":[1,2]}}', ApiError::class);
if ($error instanceof DecodeError) {
throw $error;
}
$details = $error->details->value(); // ArrayObject<array-key, JsonValue>
echo $details['field']->value(); // emailvalue() returns one level at a time: a JSON array is a list<JsonValue>, a
JSON object is an ArrayObject<array-key, JsonValue>, and everything else is a
scalar or null. new JsonValue(...) accepts the same PHP values that
json_encode() writes, and throws for others, such as closures. JsonValue
works in fields, lists, maps, and tuples, but not in unions.
A JsonValue|null field tells an absent member apart from JSON null: PHP
null means the member was absent, and JSON null decodes as a JsonValue
that holds null. Its class must implement JsonSerializable, for example by
using MappedJsonFields, which leaves out the member when the property is
null.
See the type reference for the details.
Some tools need the members that a class doesn't declare, for example to keep
vendor extensions when they rewrite a document. Mark one property with
#[OtherMembers] to collect them:
use Eventjet\Json\JsonValue;
use Eventjet\Json\MappedJsonFields;
use Eventjet\Json\OtherMembers;
final readonly class Webhook implements JsonSerializable
{
use MappedJsonFields;
/** @param array<array-key, JsonValue> $others */
public function __construct(
public string $event,
#[OtherMembers]
public array $others = [],
) {}
}
$webhook = Json::decode('{"event":"push","x-retry":2}', Webhook::class);
if ($webhook instanceof DecodeError) {
throw $webhook;
}
echo $webhook->others['x-retry']->value(); // 2
echo json_encode($webhook); // {"event":"push","x-retry":2}The property collects every member whose name matches no field's JSON name, each
as a JsonValue. The class must implement JsonSerializable, for example with
MappedJsonFields, which writes the collected members after the declared
fields. See the type reference for the details.
The package includes classes for JSON Schema documents of Draft 4, Draft 6,
Draft 7, Draft 2019-09, and Draft 2020-12, in the Eventjet\Json\Schema
namespace:
use Eventjet\Json\Schema\Draft202012;
use Eventjet\Json\Schema\Type;
$schema = Json::decode(
'{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"name":{"type":"string"}}}',
Draft202012::class,
);
if ($schema instanceof DecodeError) {
throw $schema;
}
var_dump($schema->type === Type::Object); // bool(true)
echo $schema->title ?? 'Untitled'; // Untitled
$name = $schema->properties['name'] ?? null; // Draft202012|bool|null
echo json_encode($schema, JSON_THROW_ON_ERROR); // the same documentEach keyword is a property, and null means the schema doesn't have it, so
you can read keywords with ?? and ?->. The classes parse schemas; they
don't validate them, validate instances against them, or resolve $ref.
The classes are readonly. To change a schema, call with() with the keywords
to change as named arguments. It returns a copy, and null removes a keyword:
$person = $schema->with(title: 'Person', description: null);Keywords that the draft doesn't define, such as x-* extensions, are kept in
$schema->unknownKeywords, so json_encode() writes the whole document back.
A schema that is just true or false returns an UnexpectedRootValue
error. Check for it first if your input can be one:
$data = json_decode($json);
$schema = is_bool($data) ? $data : Json::decode($json, Draft202012::class);See the type reference for the details.
- JSON member names match constructor parameter or public property names by default. For names PHP cannot use, see the example below. Unknown members are ignored, unless the class keeps them.
- Missing required constructor arguments produce an
InvalidValueerror. Missing optional arguments use their defaults. A presentnullrequires a nullable type. Omitted public properties retain their state after construction. - Values must match their declared types. For example,
"42"is rejected for anintfield. Integer JSON values are accepted forfloatfields and restored as PHP floats. - Constructor parameters need same-named public instance properties.
Nested classes and collection item classes must be final. Interfaces,
and abstract classes are unsupported.
JsonSerializableclasses require at least one#[Field]or#[OtherMembers]annotation orJsonValue|nullproperty, and a matching serializer. - Unions must identify a type unambiguously. For example,
Person|string|nullis supported;Person|Teamis not, because either class represents a JSON object.
Round trips preserve supported values and shapes, not the original JSON text.
Unknown fields are discarded unless the class keeps them, and re-encoding may
include default-valued properties omitted from the input. PHPDoc refinements
such as positive-int and non-empty-string validate the underlying scalar
type, not the narrower constraint.
Give your PHP properties the same names as the JSON fields whenever possible.
For example, use $first_name for a JSON field named first_name. No extra
setup is needed.
Some formats use names such as $ref or @type that you can't use as ordinary
PHP property names. For these cases, #[Field] lets you choose a PHP name while
keeping the original name in JSON:
use Eventjet\Json\DecodeError;
use Eventjet\Json\Field;
use Eventjet\Json\Json;
use Eventjet\Json\MappedJsonFields;
final readonly class Reference implements JsonSerializable
{
use MappedJsonFields;
public function __construct(#[Field('$ref')] public string $ref) {}
}
$reference = Json::decode('{"$ref":"#/$defs/person"}', Reference::class);
if ($reference instanceof DecodeError) {
throw $reference;
}
echo json_encode($reference, JSON_THROW_ON_ERROR);
// {"$ref":"#/$defs/person"}#[Field('$ref')] tells the decoder to read the JSON field named $ref.
The class must also implement JsonSerializable. The MappedJsonFields trait
handles that part for you, so json_encode() writes $ref back to JSON too.
Keep this as an escape hatch for names you can't match directly. It adds some overhead, especially when encoding; see the performance measurements. The field mapping reference covers the details and how to write your own serializer if needed.