For complex structures, i.e. non-scalar data types, the hydrator uses normalizers.
A normalizer converts a value into a serializable representation (normalize)
and back into the original type (denormalize). The library ships normalizers
for all PHP native structures such as enums, date types, collections and objects,
and determines on its own which one to use.
For every property, the normalizer is determined in this order:
ArrayShapeNormalizer is used (recursive).ArrayNormalizer is used (recursive).ObjectNormalizer.The normalizer is only determined once per class because it is cached in the metadata.
If you have a collection (array, iterable, list) the element type is read from the docblock and the matching normalizer is applied to every element automatically.
final readonly class ProfileCreated
{
/** @param list<Skill> $skills */
public function __construct(
public array $skills,
) {
}
}You can also set the ArrayNormalizer explicitly and pass it the normalizer
for the elements:
use DateTimeImmutable;
use Patchlevel\Hydrator\Normalizer\ArrayNormalizer;
use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer;
final class Profile
{
/** @var list<DateTimeImmutable> */
#[ArrayNormalizer(new DateTimeImmutableNormalizer())]
public array $loginDates;
}The keys of the array are kept.
If you have an array with a specific shape, the ArrayShapeNormalizer is used.
It is inferred automatically from an array{...} docblock, or you can configure
it explicitly with a map of field name to normalizer.
use DateTimeImmutable;
use Patchlevel\Hydrator\Normalizer\ArrayShapeNormalizer;
use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer;
final class Profile
{
/**
* @var array{
* createdAt: DateTimeImmutable,
* source: string
* }
*/
public array $meta;
#[ArrayShapeNormalizer(['createdAt' => new DateTimeImmutableNormalizer()])]
public array $explicitMeta;
}With the DateTimeImmutableNormalizer you can convert DateTimeImmutable
objects to a string and back again. It is applied automatically to
DateTimeImmutable properties.
use DateTimeImmutable;
use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer;
final class Profile
{
#[DateTimeImmutableNormalizer]
public DateTimeImmutable $createdAt;
}You can also define the format. Either describe it yourself as a string or use
one of the existing constants. The default is DateTimeImmutable::ATOM.
use DateTimeImmutable;
use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer;
final class Profile
{
#[DateTimeImmutableNormalizer(format: DateTimeImmutable::RFC3339_EXTENDED)]
public DateTimeImmutable $createdAt;
}You can read about how the format is structured in the php docs.
The DateTimeNormalizer works exactly like the DateTimeImmutableNormalizer,
only for DateTime objects. The default format is DateTime::ATOM.
use DateTime;
use Patchlevel\Hydrator\Normalizer\DateTimeNormalizer;
final class Profile
{
#[DateTimeNormalizer(format: DateTime::RFC3339_EXTENDED)]
public DateTime $lastSeen;
}To normalize a DateTimeZone, the DateTimeZoneNormalizer is used.
use DateTimeZone;
use Patchlevel\Hydrator\Normalizer\DateTimeZoneNormalizer;
final class Profile
{
#[DateTimeZoneNormalizer]
public DateTimeZone $timeZone;
}A DateInterval is converted to its ISO 8601 duration string with the
DateIntervalNormalizer. The format can be customized.
use DateInterval;
use Patchlevel\Hydrator\Normalizer\DateIntervalNormalizer;
final class Subscription
{
#[DateIntervalNormalizer]
public DateInterval $renewEvery;
}Backed enums are converted to their backing value. The enum class is inferred from the property type, but can also be passed explicitly.
use Patchlevel\Hydrator\Normalizer\EnumNormalizer;
final class Profile
{
#[EnumNormalizer]
public Role $role;
#[EnumNormalizer(Role::class)]
public mixed $explicitRole;
}If you have a complex object that you want to normalize, the ObjectNormalizer
is used. It runs the hydrator recursively on the object. It is
the automatic fallback for object properties, so you only need the attribute
when the class cannot be inferred from the type.
use Patchlevel\Hydrator\Normalizer\ObjectNormalizer;
final class Profile
{
#[ObjectNormalizer]
public Address $address;
#[ObjectNormalizer(Address::class)]
public object $untypedAddress;
}Circular references are not supported and result in a CircularReference exception.
Use the ObjectMapNormalizer if you have either inheritance or a union type,
where the concrete class can not be derived from the property type alone.
The map assigns a stable type name to every class, which is stored in the data
under the _type field (configurable via typeFieldName).
use Patchlevel\Hydrator\Normalizer\ObjectMapNormalizer;
#[ObjectMapNormalizer([
ContentBlock::class => 'content',
CodeBlock::class => 'code',
])]
interface Block
{
}
final class Page
{
#[ObjectMapNormalizer(
[TextSection::class => 'text', ImageSection::class => 'image'],
typeFieldName: 'kind',
)]
public TextSection|ImageSection $section;
}Auto detection of the concrete type is not possible here. You have to specify the map yourself.
The InlineNormalizer allows you to define normalization and denormalization
logic directly via closures. This is useful for simple value objects when you
don't want to create a separate normalizer class.
use Patchlevel\Hydrator\Normalizer\InlineNormalizer;
#[InlineNormalizer(
normalize: static fn (self $email): string => $email->toString(),
denormalize: static fn (string $value): self => new self($value),
)]
final class Email
{
public function __construct(
private string $value,
) {
}
public function toString(): string
{
return $this->value;
}
}Closures in attributes are only possible since PHP 8.5, therefore this normalizer can only be used as an attribute with PHP 8.5.
If you want to handle null values within your closures, you can set the
passNull option to true. By default, null values are not passed to the
closures and are returned as null directly.
The library only offers normalizers for PHP native things, so for your own
structures, such as value objects, you write a custom normalizer. It must
implement the Normalizer interface. To use it as an attribute, allow it for
properties as well as classes.
In this example we have a value object that holds a validated name:
final class Name
{
private string $value;
public function __construct(string $value)
{
if (strlen($value) < 3) {
throw new NameIsTooShort($value);
}
$this->value = $value;
}
public function toString(): string
{
return $this->value;
}
}The matching normalizer converts it to a string and back:
use Attribute;
use Patchlevel\Hydrator\Normalizer\InvalidArgument;
use Patchlevel\Hydrator\Normalizer\Normalizer;
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS)]
final class NameNormalizer implements Normalizer
{
public function normalize(mixed $value, array $context): string|null
{
if ($value === null) {
return null;
}
if (!$value instanceof Name) {
throw InvalidArgument::withWrongType(Name::class, $value);
}
return $value->toString();
}
public function denormalize(mixed $value, array $context): Name|null
{
if ($value === null) {
return null;
}
if (!is_string($value)) {
throw InvalidArgument::withWrongType('string', $value);
}
return new Name($value);
}
}Now you can use the normalizer directly on a property:
final class Profile
{
#[NameNormalizer]
public Name $name;
}Instead of specifying the normalizer on each property, you can also set the normalizer on the class or on an interface. Every property typed with that class then uses it automatically.
#[NameNormalizer]
final class Name
{
// ... same as before
}If you can't put an attribute on the class, for example for third-party classes, write a guesser instead.