Skip to content

Repository files navigation

Mutation testing badgeType CoverageLatest Stable VersionLicense

Hydrator

This library enables seamless hydration of objects to arrays—and back again. It’s optimized for both developer experience (DX) and performance.

The library is a core component of patchlevel/event-sourcing, where it powers the storage and retrieval of thousands of objects.

Hydration is handled through normalizers, especially for complex data types. The system can automatically determine the appropriate normalizer based on the data type and annotations.

In most cases, no manual configuration is needed. And if customization is required, it can be done easily using attributes.

Installation

composer require patchlevel/hydrator

Usage

To use the hydrator you just have to create an instance of it.

usePatchlevel\Hydrator\MetadataHydrator;
$hydrator = MetadataHydrator::create();

After that you can hydrate any classes or objects. Also final, readonly classes with property promotion. These objects or classes can have complex structures in the form of value objects, DTOs or collections. Or all nested together. Here's an example:

finalreadonlyclass ProfileCreated {
/** * @param list<Skill> $skills */publicfunction__construct(
publicint$id,
publicstring$name,
publicRole$role, // enum,publicarray$skills, // array of objectspublicDateTimeImmutable$createdAt,
) {
}
}

Extract Data

To convert objects into serializable arrays, you can use the extract method of the hydrator.

$event = newProfileCreated(
1, 'patchlevel',
Role::Admin,
[newSkill('php', 10), newSkill('event-sourcing', 10)],
newDateTimeImmutable('2023-10-01 12:00:00'),
);
$data = $hydrator->extract($event);

The result looks like this:

[
'id' => 1,
'name' => 'patchlevel',
'role' => 'admin',
'skills' => [
[
'name' => 'php',
'level' => 10,
],
[
'name' => 'event-sourcing',
'level' => 10,
],
],
'createdAt' => '2023-10-01T12:00:00+00:00',
]

We could now convert the whole thing into JSON using json_encode.

Hydrate Object

The process can also be reversed. Hydrate an array back into an object. To do this, we need to specify the class that should be created and the data that should then be written into it.

$event = $hydrator->hydrate(
ProfileCreated::class,
[
'id' => 1,
'name' => 'patchlevel',
'role' => 'admin',
'skills' => [
[
'name' => 'php',
'level' => 10,
],
[
'name' => 'event-sourcing',
'level' => 10,
],
],
'createdAt' => '2023-10-01T12:00:00+00:00',
]
);
$oldEvent == $event// true

Warning

It is important to know that the constructor is not called!

Normalizer

For more complex structures, i.e. non-scalar data types, we use normalizers. We have some built-in normalizers for standard structures such as objects, arrays, enums, datetime etc. You can find the full list below.

The library attempts to independently determine which normalizers should be used. For this purpose, normalizers of this order are determined:

  1. Does the class property have a normalizer as an attribute? Use this.
  2. The data type of the property is determined.
    1. If it is an array shape, use the ArrayShapeNormalizer (recursive).
    2. If it is a collection, use the ArrayNormalizer (recursive).
    3. If it is an object, then look for a normalizer as attribute on the class or interfaces and use this.
    4. If it is an object, then guess the normalizer based on the object. Fallback to the object normalizer.

The normalizer is only determined once because it is cached in the metadata. Below you will find the list of all normalizers and how to set them manually or explicitly.

Array

If you have a collection (array, iterable, list) with a data type that needs to be normalized, you can use the ArrayNormalizer and pass it the required normalizer.

usePatchlevel\Hydrator\Normalizer\ArrayNormalizer;
usePatchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer;
finalclassDTO {
/** * @var list<DateTimeImmutable> */
#[ArrayNormalizer]
publicarray$dates;
#[ArrayNormalizer(newDateTimeImmutableNormalizer())]
publicarray$explicitDates;
}

Note

The keys from the arrays are taken over here.

ArrayShape

If you have an array with a specific shape, you can use the ArrayShapeNormalizer.

usePatchlevel\Hydrator\Normalizer\ArrayShapeNormalizer;
usePatchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer;
finalclassDTO {
/** * @var array{ * date: DateTimeImmutable, * otherField: string * } */
#[ArrayShapeNormalizer]
publicarray$meta;
#[ArrayShapeNormalizer(['date' => newDateTimeImmutableNormalizer()])]
publicarray$explicitMeta;
}

DateTimeImmutable

With the DateTimeImmutable Normalizer, as the name suggests, you can convert DateTimeImmutable objects to a String and back again.

usePatchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer;
finalclassDTO {
#[DateTimeImmutableNormalizer]
publicDateTimeImmutable$date;
}

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.

usePatchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer;
finalclassDTO {
#[DateTimeImmutableNormalizer(format: DateTimeImmutable::RFC3339_EXTENDED)]
publicDateTimeImmutable$date;
}

Note

You can read about how the format is structured in the php docs.

DateTime

The DateTime Normalizer works exactly like the DateTimeNormalizer. Only for DateTime objects.

usePatchlevel\Hydrator\Normalizer\DateTimeNormalizer;
finalclassDTO {
#[DateTimeNormalizer]
publicDateTime$date;
}

You can also specify the format here. The default is DateTime::ATOM.

usePatchlevel\Hydrator\Normalizer\DateTimeNormalizer;
finalclassDTO {
#[DateTimeNormalizer(format: DateTime::RFC3339_EXTENDED)]
publicDateTime$date;
}

Note

You can read about how the format is structured in the php docs.

DateTimeZone

To normalize a DateTimeZone one can use the DateTimeZoneNormalizer.

usePatchlevel\Hydrator\Normalizer\DateTimeZoneNormalizer;
finalclassDTO
{
#[DateTimeZoneNormalizer]
publicDateTimeZone$timeZone;
}

Enum

Backed enums can also be normalized.

usePatchlevel\Hydrator\Normalizer\EnumNormalizer;
finalclassDTO
{
#[EnumNormalizer]
publicStatus$status;
}

Object

If you have a complex object that you want to normalize, you can use the ObjectNormalizer. This use the hydrator internally to normalize the object.

usePatchlevel\Hydrator\Normalizer\ObjectNormalizer;
finalclassDTO
{
#[ObjectNormalizer]
publicAnohterDto$anotherDto;
#[ObjectNormalizer(AnohterDto::class)]
publicobject$object;
}
finalclass AnotherDto
{
#[EnumNormalizer]
publicStatus$status;
}

Warning

Circular references are not supported and will result in an exception.

Custom Normalizer

Since we only offer normalizers for PHP native things, you have to write your own normalizers for your own structures, such as value objects.

In our example we have built a value object that should hold a name.

finalclass Name
{
privatestring$value;
publicfunction__construct(string$value) {
if (strlen($value) < 3) {
thrownewNameIsToShortException($value);
}
$this->value = $value;
}
publicfunctiontoString(): string {
return$this->value;
}
}

For this we now need a custom normalizer. This normalizer must implement the Normalizer interface. Finally, you have to allow the normalizer to be used as an attribute, best to allow it for properties as well as classes.

usePatchlevel\Hydrator\Normalizer\Normalizer;
usePatchlevel\Hydrator\Normalizer\InvalidArgument;
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS)]
class NameNormalizer implements Normalizer
{
publicfunctionnormalize(mixed$value): string
{
if (!$valueinstanceof Name) {
throw InvalidArgument::withWrongType(Name::class, $value);
}
return$value->toString();
}
publicfunctiondenormalize(mixed$value): ?Name
{
if ($value === null) {
returnnull;
}
if (!is_string($value)) {
throw InvalidArgument::withWrongType('string', $value);
}
returnnewName($value);
}
}

Now we can also use the normalizer directly.

finalclassDTO
{
#[NameNormalizer]
publicName$name
}

Define normalizer on class level

Instead of specifying the normalizer on each property, you can also set the normalizer on the class or on an interface.

#[NameNormalizer]
finalclass Name
{
// ... same as before
}

Guess normalizer

It's also possible to write your own guesser that finds the correct normalizer based on the object. This is useful if, for example, setting the normalizer on the class or interface isn't possible.

usePatchlevel\Hydrator\Guesser\Guesser;
useSymfony\Component\TypeInfo\Type\ObjectType;
class NameGuesser implements Guesser
{
publicfunctionguess(ObjectType$object): Normalizer|null
{
returnmatch($object->getClassName()) {
case Name::class => newNameNormalizer(),
default => null,
};
}
}

To use this Guesser, you must specify it when creating the Hydrator:

usePatchlevel\Hydrator\MetadataHydrator;
$hydrator = MetadataHydrator::create([newNameGuesser()]);

Note

The guessers are queried in order, and the first match is returned. Finally, our built-in guesser is executed.

Normalized Name

By default, the property name is used to name the field in the normalized result. This can be customized with the NormalizedName attribute.

usePatchlevel\Hydrator\Attribute\NormalizedName;
finalclassDTO
{
#[NormalizedName('profile_name')]
publicstring$name
}

The whole thing looks like this

[
'profile_name' => 'David'
]

Tip

You can also rename properties to events without having a backwards compatibility break by keeping the serialized name.

Ignore

Sometimes it is necessary to exclude properties. You can do that with the Ignore attribute. The property is ignored both when extracting and when hydrating.

usePatchlevel\Hydrator\Attribute\Ignore;
readonlyclass ProfileCreated {
publicfunction__construct(
publicstring$id,
publicstring$name,
#[Ignore]
publicstring$ignoreMe,
) {
}
}

Lazy

Since PHP 8.4, it's been possible to lazy-hydrate objects. That is, the actual hydration process occurs when the object is accessed. You can define for each class whether you want it to be lazy by using the Lazy attribute.

usePatchlevel\Hydrator\Attribute\Lazy;
#[Lazy]
readonlyclass ProfileCreated {
publicfunction__construct(
publicstring$id,
publicstring$name,
) {
}
}

Note

If you are using a PHP version older than 8.4, the attribute will be ignored.

Hooks

Sometimes you need to do something before extract or after hydrate process. For this we have the PreExtract and PostHydrate attributes.

usePatchlevel\Hydrator\Attribute\PostHydrate;
usePatchlevel\Hydrator\Attribute\PreExtract;
readonlyclass Dto { #[PostHydrate]
privatefunctionpostHydrate(): void
{
// do something
}
#[PreExtract]
privatefunctionpreExtract(): void
{
// do something
}
}

Events

Another way to intervene in the extract and hydrate process is through events. There are two events: PostExtract and PreHydrate. For this functionality we use the symfony/event-dispatcher.

usePatchlevel\Hydrator\Cryptography\PersonalDataPayloadCryptographer;
usePatchlevel\Hydrator\Cryptography\Store\CipherKeyStore;
usePatchlevel\Hydrator\Metadata\Event\EventMetadataFactory;
usePatchlevel\Hydrator\MetadataHydrator;
useSymfony\Component\EventDispatcher\EventDispatcher;
usePatchlevel\Hydrator\Event\PostExtract;
usePatchlevel\Hydrator\Event\PreHydrate;
$eventDispatcher = newEventDispatcher();
$eventDispatcher->addListener(
PostExtract::class,
staticfunction (PostExtract$event): void {
// do something
}
);
$eventDispatcher->addListener(
PreHydrate::class,
staticfunction (PreHydrate$event): void {
// do something
}
);
$hydrator = newMetadataHydrator(eventDispatcher: $eventDispatcher);

Cryptography

The library also offers the possibility to encrypt and decrypt personal data. For this purpose, a key is created for each subject ID, which is used to encrypt the personal data.

DataSubjectId

First we need to define what the subject id is.

usePatchlevel\Hydrator\Attribute\DataSubjectId;
finalclass EmailChanged
{
publicfunction__construct(
#[DataSubjectId]
publicreadonlystring$profileId,
) {
}
}

Warning

The DataSubjectId must be a string. You can use a normalizer to convert it to a string. The Subject ID cannot be personal data.

PersonalData

Next, we need to specify which fields we want to encrypt.

usePatchlevel\Hydrator\Attribute\DataSubjectId;
usePatchlevel\Hydrator\Attribute\PersonalData;
finalclassDTO {
publicfunction__construct(
#[DataSubjectId]
publicreadonlystring$profileId,
#[PersonalData]
publicreadonlystring|null$email,
) {
}
}

If the information could not be decrypted, then a fallback value is inserted. The default fallback value is null. You can change this by setting the fallback parameter. In this case unknown is added:

usePatchlevel\Hydrator\Attribute\PersonalData;
finalclassDTO
{
publicfunction__construct(
#[DataSubjectId]
publicreadonlystring$profileId,
#[PersonalData(fallback: 'unknown')]
publicreadonlystring$name,
) {
}
}

You can also use a callable as a fallback.

usePatchlevel\Hydrator\Attribute\DataSubjectId;
usePatchlevel\Hydrator\Attribute\PersonalData;
finalclass ProfileCreated
{
publicfunction__construct(
#[DataSubjectId]
publicreadonlystring$profileId,
#[PersonalData(fallback: 'deleted profile')]
publicreadonlystring$name,
#[PersonalData(fallbackCallable: [self::class, 'anonymizedEmail'])]
publicreadonlystring$email,
) {
}
publicstaticfunctionanonymizedEmail(string$subjectId): string
{
returnsprintf('%s@anno.com', $subjectId);
}
}

Tip

Cryptography is very expensive in terms of performance, you can combine it with lazy to improve performance and only decrypt when you actually access the object.

Configure Cryptography

Here we show you how to configure the cryptography.

usePatchlevel\Hydrator\Cryptography\PersonalDataPayloadCryptographer;
usePatchlevel\Hydrator\Cryptography\Store\CipherKeyStore;
usePatchlevel\Hydrator\Metadata\Event\EventMetadataFactory;
usePatchlevel\Hydrator\MetadataHydrator;
$cipherKeyStore = newInMemoryCipherKeyStore();
$cryptographer = PersonalDataPayloadCryptographer::createWithDefaultSettings($cipherKeyStore);
$hydrator = newMetadataHydrator(cryptographer: $cryptographer);

Warning

We recommend to use the useEncryptedFieldName option to recognize encrypted fields. This allows data to be encrypted later without big troubles.

Cipher Key Store

The keys must be stored somewhere. For testing purposes, we offer an in-memory implementation.

usePatchlevel\Hydrator\Cryptography\Cipher\CipherKey;
usePatchlevel\Hydrator\Cryptography\Store\InMemoryCipherKeyStore;
$cipherKeyStore = newInMemoryCipherKeyStore();
/** @var CipherKey $cipherKey */$cipherKeyStore->store('foo-id', $cipherKey);
$cipherKey = $cipherKeyStore->get('foo-id');
$cipherKeyStore->remove('foo-id');

Because we don't know where you want to store the keys, we don't offer any other implementations. You should use a database or a key store for this. To do this, you have to implement the CipherKeyStore interface.

Remove personal data

To remove personal data, you need only remove the key from the store.

$cipherKeyStore->remove('foo-id');

About

This library enables seamless hydration of objects to arrays—and back again. It’s optimized for both developer experience (DX) and performance.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages