Skip to content

About

Type-safe JSON for PHP

Resources

Contributing

Stars

0 stars

Watchers

1 watching

Forks

Repository files navigation

eventjet/json

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.

Installation

Requires PHP 8.4 or later, with the JSON and Tokenizer extensions. There are no other runtime dependencies.

composer require eventjet/json

Quick start

Define 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.

Handling errors

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.

Validate your classes in unit tests

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.

Nested objects and collections

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; // Grace

Put 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.

Literal types and constants

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.

Root arrays and maps

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; // Ada

Root 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.

Free-form JSON

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(); // email

value() 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.

Keeping unknown members

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.

JSON Schema

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 document

Each 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.

Decoding rules

  • 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 InvalidValue error. Missing optional arguments use their defaults. A present null requires a nullable type. Omitted public properties retain their state after construction.
  • Values must match their declared types. For example, "42" is rejected for an int field. Integer JSON values are accepted for float fields 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. JsonSerializable classes require at least one #[Field] or #[OtherMembers] annotation or JsonValue|null property, and a matching serializer.
  • Unions must identify a type unambiguously. For example, Person|string|null is supported; Person|Team is 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.

When JSON names don't work as PHP property names

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.

Documentation

License

MIT

About

Type-safe JSON for PHP

Resources

Contributing

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages