ADR 0001: Use JsonObject for complex objects & maps in generated payloads¶
- Status: Accepted
- Decided: 2026-08, while fixing #700 and #680
- Supersedes: the approach of PR #704 (
\ArrayObjectfor objects), which was never merged
Context¶
Jane generated normalizers that built object-shaped payloads with plain PHP arrays:
- a schema declaring
type: object+additionalProperties(a map, guessed asMapType) initialized its normalization accumulator with$values = []; patternPropertiesmaps (PatternMultipleType) had the same asymmetry;- nested named-object properties delegated to
$this->normalizer->normalize(...), which returns an array — empty when all sub-properties were skipped.
Because PHP encodes an empty array as [] and not {}, any empty JSON
object produced by these paths was serialized as an empty JSON array. APIs
that require real objects (Docker's ExposedPorts, free-form metadata fields,
nullable dictionaries explicitly set to null, …) rejected such payloads.
Worse, normalizing an explicit null into a nullable map iterated over null
and still emitted "field": [].
The historical fix attempt (PR #704) wrapped every payload in
\ArrayObject([], \ArrayObject::ARRAY_AS_PROPS). It worked but was rejected by
the maintainer: ArrayObject has many flaws and caused issues in the past. A
custom lightweight class was preferred instead.
A hard constraint discovered along the way: generated model normalizers
implement Symfony's NormalizerInterface, whose normalize() return type is
declared as array|string|int|float|bool|\ArrayObject|null. PHP forbids
widening that union in implementations — so no new class may ever be returned
by normalize() itself, and a \stdClass-based value would be a runtime
TypeError if it were.
Decision¶
Every generated library ships a dedicated container class,
<Namespace>\Runtime\JsonObject:
class JsonObject extends \stdClass implements \ArrayAccess, \Countable, \IteratorAggregate
Storage is the inherited dynamic public properties of \stdClass. This single
choice gives us everything we need with zero magic:
json_encode(new JsonObject())→{}, and filled instances encode as proper JSON objects (public dynamic properties);$object['key'] = …keeps working in generated loops (ArrayAccess);$object->keymirrors the oldARRAY_AS_PROPSergonomics;- it is
iterable, satisfying theiterabletype hints on model getters and setters; - it passes
(array)casts cleanly, which matters for multipart form bodies.
Where each JSON shape is represented in normalized payloads:
| JSON shape | Schema source | Normalized representation |
|---|---|---|
| Named object | type: object with properties, referenced models |
Generated model class |
| Free-form object / map | additionalProperties (MapType) |
<Ns>\Runtime\JsonObject |
| Pattern-keyed map | patternProperties (PatternMultipleType) |
<Ns>\Runtime\JsonObject |
| Array | type: array items |
Plain PHP list array |
| Scalar / mixed | string, int, … , unconstrained |
Passthrough scalar |
Implementation rules baked into the generators:
- Both the denormalization and normalization map accumulators instantiate
JsonObject(MapType/PatternMultipleTypevalue statements). - Nested named-object properties wrap the inner
normalize()result:$value === null ? null : new JsonObject($this->normalizer->normalize(...)). The null guard also fixes fatal errors on nullable object properties. - Nullable non-required properties get an explicit
null !== $object->getX()guard next toisInitialized(), so an initialized-but-null property emitsnull(or is omitted underskip-null-values) instead of an empty collection. JsonObjectonly ever appears as values inside the array returned bynormalize()— never as the return value itself (see constraint above).- Multipart form endpoints serialize form values with
$serializer->serialize((array) $value, 'json')when the value is an array or a\stdClassinstance;(array)is a no-op on arrays and convertsJsonObjectback to its assoc array, preserving the wire format exactly.
Consequences¶
- Empty objects/maps now serialize as
{}everywhere; normalize and denormalize are symmetric. - BC break: consumers post-processing raw normalized arrays now encounter
JsonObjectinstances instead of plain arrays/\ArrayObjectwhere maps and objects live. Code callinggetArrayCopy()or relying on==comparisons against arrays must adapt (use->toArray(),iterator_to_array()or casts). mixed-typed values remain passthrough: an empty associative array stored in an unconstrained field still encodes as[]— statically undecidable.- Models that represent extension containers no longer extend
\ArrayObject: since #867 they use the per-library<Ns>\Runtime\AdditionalAndPatternPropertiestrait with the<Ns>\Runtime\AdditionalPropertiesInterfaceinterface (see ADR 0003), exposing defined and additional values alike throughforeach,ArrayAccess,count(),toArray()andjson_encode. - The class ships via the per-library runtime copy mechanism
(
JsonSchema/Generator/Runtime/data/JsonObject.php), so every generated library owns its own copy namespaced under its ownRuntime\namespace — no cross-library coupling.
Links¶
- Issue #700 — empty
additionalPropertiesobject generated as an array - Issue #680 — empty objects in general
- PR #704 — the
\ArrayObjectapproach and the maintainer's rationale for rejecting it - Korbeil's jane-v8 experiment