Vasiliy Krivoplyas
← Back to home

PHP · DTO · JSON · Struct

Struct: data contracts between JSON and PHP

Moving between JSON and a typed PHP object can lose more than expected: field presence, identifier precision, or the distinction between an object and a list. In Struct 2.0, I made these decisions explicit, from incoming values to outbound payloads.

The contract at the data boundary

A native public readonly int property does not answer every question about external data. Is a numeric string allowed? Is a nullable parameter required? What happens to an unknown key or two names for one field? What will the next API receive after serialization? Distributing these decisions across a controller, mapper and serializer can produce inconsistent input and output rules.

Struct is a small PHP 8.1+ library that maps arrays and JSON into validated DTOs and produces outbound payloads. Attributes define the fields; mapping, validation and export follow a defined order. Business decisions remain in application code: the library checks the declared data contract, not whether an operation using that data is allowed.

The following sections cover version 2.0 decisions: presence versus null, strict types, collection shape, input and output names, and copying. These details determine whether a conversion can be relied on at an integration boundary.

A missing field is not null

Nullability describes an allowed value; requiredness describes key presence. Thus #[Field] public readonly ?string $comment requires comment in the payload but accepts an explicit null. Missing keys and null values are checked separately with array_key_exists.

An optional non-nullable field needs a default: the object must not retain an uninitialized property. A null default is an explicitly supplied default too. DefaultValue::Missing is a separate marker distinguishing an omitted default from a null default.

Field declaration Missing key Null value
string $comment Error Error
?string $comment Error Allowed by type
?string $comment + required: false Uses null Allowed by type
string $comment + default: 'none' Uses 'none' Error
?string $comment + default: null Uses null Allowed by type

The table assumes #[Field]; default and required are attribute parameters. Validation rules can additionally reject null even when the type allows it. After mapping, a DTO does not retain whether its final null came from input or a default. PATCH operations that depend on that distinction need separate presence tracking.

Strict types and large identifiers

The mapper checks a value before assigning it through Reflection. The string "2" is not accepted as int, nor 1 as bool. Both the attribute’s mapping type and the property’s native type are checked. Float fields allow int-to-float widening; numeric strings require explicit transformers. Silent coercion must not hide an input contract violation.

Large JSON integers form another boundary. fromJson() always enables JSON_BIGINT_AS_STRING: an integer beyond the PHP int range becomes an exact decimal string instead of a float that loses digits. A strict int field rejects it; string|int retains ordinary integers or large strings.

Built-in mapping also constructs a nested Struct from an array or JSON object, a backed enum from a value of its backing type, and DateTimeImmutable from a date string. These cases need no custom transformer. Custom value objects are supplied as existing instances or created by an explicit transformer: Struct does not invoke an arbitrary class constructor with an input array.

PHP 8.1+ · an identifier beyond the int range
use tommyknocker\struct\Field;
use tommyknocker\struct\Struct;

final class ExternalReference extends Struct
{
    #[Field]
    public readonly string|int $id;
}

$reference = ExternalReference::fromJson('{"id":18446744073709551616}');
// $reference->id === '18446744073709551616'
// $reference->toJson() === '{"id":"18446744073709551616"}'

The large value is exported as a JSON string. This precision-preserving policy must be accepted by the receiving API; the library does not promise to reproduce the original JSON numeric type at any magnitude. Unions try an exact type match before conversions, so a string does not accidentally become another union alternative.

Objects and lists must retain their intended shape

Associative JSON decoding turns both {} and [] into an empty PHP array. An object with keys "0" and "1" can likewise become a list during the next json_encode. For an API, that can change the payload type even though element values remain unchanged.

Struct decodes JSON objects as objects and makes the policy for typed collections explicit. List accepts only a sequential list. Map preserves keys and exports an object, including an empty one. Preserve records whether the input was a JSON object or a list. In this example, each element is a string and the collection policy is Preserve.

PHP 8.1+ · empty objects, empty lists and numeric keys
use tommyknocker\struct\CollectionType;
use tommyknocker\struct\Field;
use tommyknocker\struct\Struct;

final class Labels extends Struct
{
    #[Field('string', collection: CollectionType::Preserve)]
    public readonly array $labels;
}

$map = Labels::fromJson('{"labels":{}}');
$list = Labels::fromJson('{"labels":[]}');
$numericMap = Labels::fromJson('{"labels":{"0":"a","1":"b"}}');

// $map->toJson()        === '{"labels":{}}'
// $list->toJson()       === '{"labels":[]}'
// $numericMap->toJson() === '{"labels":{"0":"a","1":"b"}}'

toArray() returns PHP arrays and does not carry all JSON-shape information. Use toJson() to preserve that shape. For input that is already a PHP array, Preserve derives shape from its keys; [] is a list. If an empty PHP array must be exported as an object, declare Map explicitly.

One pipeline for input, defaults and validation

Processing order is part of the public contract. Model schema and input keys are checked first, then an input value or default is selected. Transformers receive that value; mapping is followed by element and field rules, then native-type checking and readonly assignment.

Defaults are not a trusted bypass: they pass through transformations and validation too. The same applies to null: a type can allow it while a rule rejects it. For collections, itemRules validate elements and validationRules validate the whole collection. CountRule expresses “at least one element,” because an empty array has no elements to check.

Input failures and model errors are distinct. A missing required field raises FieldNotFoundException; a type, validation-rule or key-policy violation raises ValidationException. LogicException signals an invalid model declaration. Malformed JSON raises JsonException; a list or scalar at the root of valid JSON raises ValidationException because fromJson() requires an object. Mapping stops at the first error; callers must arrange any form-style error aggregation.

Nested failures retain a path such as items[2].quantity. A transformer can explicitly reject input with TransformationException; the mapper adds field context and preserves the cause. Programming errors and unrelated exceptions are not automatically converted into user-data validation failures.

  1. Schema and keysField types, name collisions and unknown-key policy
  2. Input value or defaultPresence is checked separately from null
  3. Transformers → mappingExplicit normalization, types, nested DTOs and collection shape
  4. Item rules → field rulesElement checks and whole-field validation
  5. Native type → readonly fieldPHP type verification before assignment
DTO construction order. Serialization is a separate stage with its own contract.

The outbound payload is explicit

alias defines an alternative input name; the property name is also accepted when the alias is absent. outputAlias defines an output name. Supplying both the alias and property name is rejected: the mapper does not choose a winner based on key order. Input-name collisions and duplicate output names are checked during metadata compilation.

Only #[Field] properties are exported. Ordinary object state does not enter the payload, nor do ignored unknown input keys. The default policy tolerates additive upstream fields; protected const STRICT = true rejects unknown input keys for a particular DTO.

PHP 8.1+ · input names, output names and explicit fields
use tommyknocker\struct\Field;
use tommyknocker\struct\Struct;

final class Message extends Struct
{
    #[Field(alias: 'message_id', outputAlias: 'id')]
    public readonly string $messageId;

    #[Field]
    public readonly string $text;

    public string $debug = 'local';
}

$message = Message::fromJson(
    '{"message_id":"m-7","text":"ready","extra":"upstream"}'
);
// $message->toArray() === ['id' => 'm-7', 'text' => 'ready']

The Field set is a declared projection, not an automatic access policy. A password marked Field will be exported. Input requests and public responses with different field sets need separate DTOs. Likewise, outputAlias does not automatically become an input alias: import/export symmetry must be declared explicitly.

During export, a backed enum becomes its backing value, a pure enum its case name, and a date a string retaining its UTC offset and microseconds. DateTimeImmutable, for example, does not become a JSON object exposing internal properties: the serialization contract defines its representation.

A DTO copy must not normalize old values again

Reconstructing a DTO from its own array seems like a simple copying method. A non-idempotent transformer, however, changes an already transformed value again. A reference prefix, for example, would be added twice.

with() operates on mapped values: all fields are revalidated, but transformers run only for explicit replacements. Misspelled replacement keys are rejected even when a DTO permits unknown keys on initial input. Unchanged Preserve collections retain their shape.

PHP 8.1+ · non-idempotent transformation and copying
use tommyknocker\struct\Field;
use tommyknocker\struct\Struct;
use tommyknocker\struct\transformation\TransformerInterface;

final class PrefixReference implements TransformerInterface
{
    public function transform(mixed $value): mixed
    {
        return is_string($value) ? 'ref-' . $value : $value;
    }
}

final class Record extends Struct
{
    #[Field(transformers: [new PrefixReference()])]
    public readonly string $reference;

    #[Field]
    public readonly int $attempts;
}

$original = new Record(['reference' => '42', 'attempts' => 0]);
$updated = $original->with(['attempts' => 1]);
// $updated->reference === 'ref-42', not 'ref-ref-42'
// $original->attempts === 0; $updated->attempts === 1

Readonly here applies to mapped-property assignment. Nested mutable objects can change internally, and with creates a shallow copy that shares those objects with the original. The library does not promise deep immutability; transformers should avoid mutating supplied objects.

Boundary cases drive verification

Testing only “array in, object out” is insufficient for these contracts. Tests cover missing and explicit null values, defaults, invalid native types, large integers, collection shape, name collisions and repeated transformation during copying. Documentation examples execute and check results, including mapping a partner order into a different outbound contract.

CI targets PHP 8.1–8.5 and PHPStan runs at level 9. Alongside tests and executable examples, an isolated Composer-distribution check installs the package and runs installed examples. This checks delivery of the library as well as a checkout containing development files.

Scaffolding has a defined scope too: the CLI statically parses PHP and adds type-based Field markers without executing the target file. It does not guess validation rules, aliases or defaults from property names. Developers declare rules that affect a data contract.

When this layer is useful

Struct is useful where an application receives or sends structured data: APIs, webhooks, queues and batch imports. It provides one place for explicit mapping and export rules, independent of a framework and without external runtime dependencies. The cost is declaring DTOs, learning the library’s API and maintaining contracts as integrations evolve.

If an existing mapper and validator already provide the required rules, another layer needs a concrete justification. Evaluate it with your own payload: how are missing and null values distinguished, is JSON shape retained, which fields are exported, and what happens during copying? These are practical criteria for assessing a small library.

Install the package with composer require tommyknocker/struct.

← Back to home