Skip to content

ObjectToArrayMapper does not serialize single nested object properties #2258

Description

@radoslav-grencik

Tempest version

3.18

PHP version

8.5

Operating system

Linux

Description

When mapping an object to an array or JSON, ObjectToArrayMapper serializes
top-level scalar/date properties correctly, and it also serializes arrays of
objects correctly. However, a single nested object property is left as an object
instead of being recursively mapped to an array.

This becomes visible when the nested object contains a DateTime property:
top-level dates are serialized using the configured date serializer, but dates
inside the single nested object are later JSON-encoded as the internal public
structure of the DateTime object.

Current behavior

Given an object like this:

use Tempest\DateTime\DateTime;
use Tempest\DateTime\FormatPattern;
use Tempest\Validation\Rules\HasDateTimeFormat;

final class ChildObject
{
    public function __construct(
        #[HasDateTimeFormat(FormatPattern::ISO8601)]
        public DateTime $createdAt,
    ) {}
}

final class ParentObject
{
    public function __construct(
        #[HasDateTimeFormat(FormatPattern::ISO8601)]
        public DateTime $createdAt,
        public ChildObject $child,
        /** @var ChildObject[] */
        public array $children,
    ) {}
}

And this mapper call:

$date = DateTime::parse('2026-08-19T12:34:56+00:00');

$array = map(new ParentObject(
    createdAt: $date,
    child: new ChildObject($date),
    children: [new ChildObject($date)],
))->toArray();

The resulting array serializes the top-level date and the array-of-objects date,
but leaves the single nested child object unmapped:

[
    'createdAt' => '2026-08-19T12:34:56.000Z',
    'child' => ChildObject { ... },
    'children' => [
        ['createdAt' => '2026-08-19T12:34:56.000Z'],
    ],
]

When using toJson(), the nested date is then encoded as the public structure of
the Tempest\DateTime\DateTime object, instead of as the configured date string:

{
  "createdAt": "2026-08-19T12:34:56.000Z",
  "child": {
    "createdAt": {
      "timezone": "UTC",
      "timestamp": {
        "seconds": 1787142896,
        "nanoseconds": 0
      },
      "year": 2026,
      "month": 8,
      "day": 19,
      "hours": 12,
      "minutes": 34,
      "seconds": 56,
      "nanoseconds": 0
    }
  },
  "children": [
    {
      "createdAt": "2026-08-19T12:34:56.000Z"
    }
  ]
}

Expected behavior

Single nested object properties should be recursively mapped in the same way as
objects inside array properties.

Expected toArray() output:

[
    'createdAt' => '2026-08-19T12:34:56.000Z',
    'child' => [
        'createdAt' => '2026-08-19T12:34:56.000Z',
    ],
    'children' => [
        ['createdAt' => '2026-08-19T12:34:56.000Z'],
    ],
]

Expected toJson() output should contain the nested date as a string as well:

{
  "createdAt": "2026-08-19T12:34:56.000Z",
  "child": {
    "createdAt": "2026-08-19T12:34:56.000Z"
  },
  "children": [
    {
      "createdAt": "2026-08-19T12:34:56.000Z"
    }
  ]
}

Possible cause

ObjectToArrayMapper::resolvePropertyValue() currently handles iterable object
properties recursively:

if ($property->getIterableType()?->isClass()) {
    foreach ($propertyValue as $key => $value) {
        if (! is_object($value)) {
            continue;
        }

        $propertyValue[$key] = map($value)->toArray();
    }

    return $propertyValue;
}

But single object properties do not seem to go through an equivalent recursive
object-to-array mapping step. If no serializer is selected for that property,
the object is returned as-is.

Suggested regression tests

This could be covered with tests in ObjectToArrayMapperTest and
ObjectToJsonMapperTest:

#[Test]
public function object_with_single_nested_object_to_array(): void
{
    $date = DateTime::parse('2026-08-19T12:34:56+00:00');

    $array = map(new ParentObject(
        createdAt: $date,
        child: new ChildObject($date),
        children: [new ChildObject($date)],
    ))->toArray();

    $this->assertSame(
        [
            'createdAt' => '2026-08-19T12:34:56.000Z',
            'child' => [
                'createdAt' => '2026-08-19T12:34:56.000Z',
            ],
            'children' => [
                ['createdAt' => '2026-08-19T12:34:56.000Z'],
            ],
        ],
        $array,
    );
}

If this behavior is considered unintended, I can send a focused PR with regression tests for ObjectToArrayMapper and ObjectToJsonMapper.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions