From ff9106020c2a3927628daa44b6b54b451cf55abc Mon Sep 17 00:00:00 2001 From: David Badura Date: Sat, 13 Jun 2026 09:30:48 +0200 Subject: [PATCH 1/3] add docs --- README.md | 808 ++-------------------------------------- composer.json | 10 +- docs/caching.md | 42 +++ docs/cryptography.md | 205 ++++++++++ docs/extensions.md | 129 +++++++ docs/getting-started.md | 138 +++++++ docs/guesser.md | 81 ++++ docs/hydrator.md | 194 ++++++++++ docs/index.md | 35 ++ docs/lazy.md | 71 ++++ docs/lifecycle-hooks.md | 104 ++++++ docs/normalizer.md | 375 +++++++++++++++++++ docs/project.json | 24 ++ 13 files changed, 1427 insertions(+), 789 deletions(-) create mode 100644 docs/caching.md create mode 100644 docs/cryptography.md create mode 100644 docs/extensions.md create mode 100644 docs/getting-started.md create mode 100644 docs/guesser.md create mode 100644 docs/hydrator.md create mode 100644 docs/index.md create mode 100644 docs/lazy.md create mode 100644 docs/lifecycle-hooks.md create mode 100644 docs/normalizer.md create mode 100644 docs/project.json diff --git a/README.md b/README.md index a5e8354a..600e27d6 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,21 @@ # 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](ttps://github.com/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. +"A library for seamless hydration of objects to arrays - and back again, +optimized for developer experience and performance." + +## Features + +* Extract objects to arrays and hydrate them back, without calling the constructor +* Works with `final`, `readonly` classes, property promotion and deeply nested structures +* Automatic normalizer resolution for enums, date types, collections, array shapes and objects +* Rename or exclude fields with attributes +* Lazy hydration of objects with PHP 8.4 lazy proxies +* Pluggable guessers and extensions to customize the process +* Safe usage of Personal Data with crypto-shredding +* Metadata caching with any PSR-6 or PSR-16 cache +* Developer experience oriented and fully typed +* and much more... ## Installation @@ -22,786 +26,18 @@ And if customization is required, it can be done easily using attributes. composer require patchlevel/hydrator ``` -## Usage - -To use the hydrator you just have to create an instance of it. - -```php -use Patchlevel\Hydrator\StackHydrator; - -$hydrator = StackHydrator::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: - -```php -final readonly class ProfileCreated -{ - /** - * @param list $skills - */ - public function __construct( - public int $id, - public string $name, - public Role $role, // enum, - public array $skills, // array of objects - public DateTimeImmutable $createdAt, - ) { - } -} -``` - -### Extract Data - -To convert objects into serializable arrays, you can use the `extract` method of the hydrator. - -```php -$event = new ProfileCreated( - 1, - 'patchlevel', - Role::Admin, - [new Skill('php', 10), new Skill('event-sourcing', 10)], - new DateTimeImmutable('2023-10-01 12:00:00'), -); - -$data = $hydrator->extract($event); -``` - -The result looks like this: - -```php -[ - '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. - -```php -$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! - -#### Object to populate - -If you want to hydrate an object that already exists, you can specify the object to populate. -This is useful if you want to update an existing object. - -```php -$dto = new Dto(); - -$event = $hydrator->hydrate( - $dto::class, - [ - 'name' => 'patchlevel', - ], [ - MetadataHydrator::OBJECT_TO_POPULATE => $dto, - ], -); -``` - -### 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. - -```php -use Patchlevel\Hydrator\Normalizer\ArrayNormalizer; -use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer; - -final class DTO -{ - /** - * @var list - */ - #[ArrayNormalizer] - public array $dates; - - #[ArrayNormalizer(new DateTimeImmutableNormalizer())] - public array $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`. - -```php -use Patchlevel\Hydrator\Normalizer\ArrayShapeNormalizer; -use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer; - -final class DTO -{ - /** - * @var array{ - * date: DateTimeImmutable, - * otherField: string - * } - */ - #[ArrayShapeNormalizer] - public array $meta; - - #[ArrayShapeNormalizer(['date' => new DateTimeImmutableNormalizer()])] - public array $explicitMeta; -} -``` - -#### DateTimeImmutable - -With the `DateTimeImmutable` Normalizer, as the name suggests, -you can convert DateTimeImmutable objects to a String and back again. - -```php -use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer; - -final class DTO -{ - #[DateTimeImmutableNormalizer] - public DateTimeImmutable $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`. - -```php -use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer; - -final class DTO -{ - #[DateTimeImmutableNormalizer(format: DateTimeImmutable::RFC3339_EXTENDED)] - public DateTimeImmutable $date; -} -``` - -> [!NOTE] -> You can read about how the format is structured in the [php docs](https://www.php.net/manual/de/datetime.format.php). - -#### DateTime - -The `DateTime` Normalizer works exactly like the DateTimeNormalizer. Only for DateTime objects. - -```php -use Patchlevel\Hydrator\Normalizer\DateTimeNormalizer; - -final class DTO -{ - #[DateTimeNormalizer] - public DateTime $date; -} -``` - -You can also specify the format here. The default is `DateTime::ATOM`. - -```php -use Patchlevel\Hydrator\Normalizer\DateTimeNormalizer; - -final class DTO -{ - #[DateTimeNormalizer(format: DateTime::RFC3339_EXTENDED)] - public DateTime $date; -} -``` - -> [!NOTE] -> You can read about how the format is structured in the [php docs](https://www.php.net/manual/de/datetime.format.php). - -#### DateTimeZone - -To normalize a `DateTimeZone` one can use the `DateTimeZoneNormalizer`. - -```php -use Patchlevel\Hydrator\Normalizer\DateTimeZoneNormalizer; - -final class DTO -{ - #[DateTimeZoneNormalizer] - public DateTimeZone $timeZone; -} -``` - -#### Enum - -Backed enums can also be normalized. - -```php -use Patchlevel\Hydrator\Normalizer\EnumNormalizer; - -final class DTO -{ - #[EnumNormalizer] - public Status $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. - -```php -use Patchlevel\Hydrator\Normalizer\ObjectNormalizer; - -final class DTO -{ - #[ObjectNormalizer] - public AnohterDto $anotherDto; - - #[ObjectNormalizer(AnohterDto::class)] - public object $object; -} - -final class AnotherDto -{ - #[EnumNormalizer] - public Status $status; -} -``` - -> [!WARNING] -> Circular references are not supported and will result in an exception. - -#### ObjectMap - -You can also use the `ObjectMapNormalizer` if you have either inheritance or a union type. - -```php -use Patchlevel\Hydrator\Normalizer\ObjectMapNormalizer; - -// inheritance -#[ObjectMapNormalizer([ - ContentBlock::class => 'content', - CodeBlock::class => 'code' -])] -interface Block -{ -} - -// union type -class ContentBlock implements Block { - #[ObjectMapNormalizer([ - ContentA::class => 'content', - ContentB::class => 'code' - ])] - public ContentA|ContentB $content; -} -``` - -> [!NOTE] -> Auto detection of the type is not possible. You have to specify the type yourself. - -#### Inline - -The `InlineNormalizer` allows you to define normalization and denormalization logic directly via closures. -This is useful for simple value objects or when you don't want to create a separate normalizer class. - -```php -use Patchlevel\Hydrator\Normalizer\InlineNormalizer; - -#[InlineNormalizer( - normalize: static function (self $object): string { - return $object->toString(); - }, - denormalize: static function (string $value): self { - return new self($value); - }, -)] -final class Email -{ - public function __construct( - private string $value - ) {} - - public function toString(): string - { - return $this->value; - } -} -``` - -> [!NOTE] -> Closures in attributes can only be used since PHP 8.5, therefore this normalizer can only be used with PHP 8.5. - -> [!TIP] -> 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. - -### 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. - -```php -final class Name -{ - private string $value; - - public function __construct(string $value) - { - if (strlen($value) < 3) { - throw new NameIsToShortException($value); - } - - $this->value = $value; - } - - public function toString(): 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. - -```php -use Patchlevel\Hydrator\Normalizer\Normalizer; -use Patchlevel\Hydrator\Normalizer\InvalidArgument; - -#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS)] -class NameNormalizer implements Normalizer -{ - public function normalize(mixed $value): string - { - if (!$value instanceof Name) { - throw InvalidArgument::withWrongType(Name::class, $value); - } - - return $value->toString(); - } - - public function denormalize(mixed $value): ?Name - { - if ($value === null) { - return null; - } - - if (!is_string($value)) { - throw InvalidArgument::withWrongType('string', $value); - } - - return new Name($value); - } -} -``` - -Now we can also use the normalizer directly. - -```php -final class DTO -{ - #[NameNormalizer] - public Name $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. - -```php -#[NameNormalizer] -final class 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. - -```php -use Patchlevel\Hydrator\Guesser\Guesser; -use Symfony\Component\TypeInfo\Type\ObjectType; - -class NameGuesser implements Guesser -{ - public function guess(ObjectType $object): Normalizer|null - { - return match($object->getClassName()) { - case Name::class => new NameNormalizer(), - default => null, - }; - } -} -``` - -To use this Guesser, you must specify it when creating the Hydrator: - -```php -use Patchlevel\Hydrator\StackHydrator; - -$hydrator = StackHydrator::create([new NameGuesser()]); -``` +## Documentation -> [!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. - -```php -use Patchlevel\Hydrator\Attribute\NormalizedName; - -final class DTO -{ - #[NormalizedName('profile_name')] - public string $name -} -``` - -The whole thing looks like this - -```php -[ - '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. - -```php -use Patchlevel\Hydrator\Attribute\Ignore; - -readonly class ProfileCreated -{ - public function __construct( - public string $id, - public string $name, - #[Ignore] - public string $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. - -```php -use Patchlevel\Hydrator\Attribute\Lazy; - -#[Lazy] -readonly class ProfileCreated -{ - public function __construct( - public string $id, - public string $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. - -```php -use Patchlevel\Hydrator\Attribute\PostHydrate; -use Patchlevel\Hydrator\Attribute\PreExtract; - -readonly class Dto -{ - #[PostHydrate] - private function postHydrate(): void - { - // do something - } - - #[PreExtract] - private function preExtract(): void - { - // do something - } -} -``` +* Latest [Docs](https://patchlevel.dev/docs/hydrator/latest) +* Related [Blog](https://patchlevel.dev/blog) -### 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](https://symfony.com/doc/current/components/event_dispatcher.html). - -```php -use Patchlevel\Hydrator\Cryptography\SensitiveDataPayloadCryptographer; -use Patchlevel\Hydrator\Cryptography\Store\CipherKeyStore; -use Patchlevel\Hydrator\Metadata\Event\EventMetadataFactory; -use Patchlevel\Hydrator\StackHydrator; -use Symfony\Component\EventDispatcher\EventDispatcher; -use Patchlevel\Hydrator\Event\PostExtract; -use Patchlevel\Hydrator\Event\PreHydrate; - -$eventDispatcher = new EventDispatcher(); - -$eventDispatcher->addListener( - PostExtract::class, - static function (PostExtract $event): void { - // do something - } -); - -$eventDispatcher->addListener( - PreHydrate::class, - static function (PreHydrate $event): void { - // do something - } -); - -$hydrator = new StackHydrator(eventDispatcher: $eventDispatcher); -``` - -### Cryptography - -The library also offers the possibility to encrypt and decrypt sensitive data, e.g. personal data of customers. -For this purpose, a key is created for each subject ID, which is used to encrypt the sensitive data. - -#### DataSubjectId - -First we need to define what the subject id is. - -```php -use Patchlevel\Hydrator\Attribute\DataSubjectId; - -final class EmailChanged -{ - public function __construct( - #[DataSubjectId] - public readonly string $profileId, - ) { - } -} -``` - -> [!WARNING] -> The `DataSubjectId` must be a string. You can use a normalizer to convert it to a string. -> The Subject ID cannot be sensitive data. - -First we need to define what the subject id is. - -```php -use Patchlevel\Hydrator\Attribute\DataSubjectId; - -final class EmailChanged -{ - public function __construct( - #[DataSubjectId(name: 'profile')] - public readonly string $profileId, - ) { - } -} -``` - -You can also use multiple data subject id's in one event by defining the name of the subject id's. - -```php -use Patchlevel\Hydrator\Attribute\DataSubjectId; -use Patchlevel\Hydrator\Attribute\SensitiveData; - -final class DTO -{ - public function __construct( - #[DataSubjectId(name: 'profile1')] - public readonly string $profile1Id, - #[SensitiveData(subjectIdName: 'profile1')] - public readonly string|null $email1, - #[DataSubjectId(name: 'profile2')] - public readonly string $profile2Id, - #[SensitiveData(subjectIdName: 'profile2')] - public readonly string|null $email2, - ) { - } -} -``` - -> [!NOTE] -> The default name of `DataSubjectId` is `default`. - -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: - -```php -use Patchlevel\Hydrator\Attribute\SensitiveData; - -final class DTO -{ - public function __construct( - #[DataSubjectId] - public readonly string $profileId, - #[SensitiveData(fallback: 'unknown')] - public readonly string $name, - ) { - } -} -``` - -You can also use a callable as a fallback. - -```php -use Patchlevel\Hydrator\Attribute\DataSubjectId; -use Patchlevel\Hydrator\Attribute\SensitiveData; - -final class ProfileCreated -{ - public function __construct( - #[DataSubjectId] - public readonly string $profileId, - #[SensitiveData(fallback: 'deleted profile')] - public readonly string $name, - #[SensitiveData(fallbackCallable: [self::class, 'anonymizedEmail'])] - public readonly string $email, - ) { - } - - public static function anonymizedEmail(string $subjectId): string - { - return sprintf('%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. - -```php -use Patchlevel\Hydrator\Cryptography\SensitiveDataPayloadCryptographer; -use Patchlevel\Hydrator\Cryptography\Store\CipherKeyStore; -use Patchlevel\Hydrator\Metadata\Event\EventMetadataFactory; -use Patchlevel\Hydrator\StackHydrator; - -$cipherKeyStore = new InMemoryCipherKeyStore(); -$cryptographer = SensitiveDataPayloadCryptographer::createWithDefaultSettings($cipherKeyStore); -$hydrator = new StackHydrator(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. - -```php -use Patchlevel\Hydrator\Cryptography\Cipher\CipherKey; -use Patchlevel\Hydrator\Cryptography\Store\InMemoryCipherKeyStore; - -$cipherKeyStore = new InMemoryCipherKeyStore(); - -/** @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. - -```php -$cipherKeyStore->remove('foo-id'); -``` +## Integration +* [Event Sourcing](https://github.com/patchlevel/event-sourcing) ## Contributing We are open to contributions as long as they are in line with -our [BC-Policy](https://event-sourcing.patchlevel.io/latest/our-backward-compatibility-promise/). +our [BC-Policy](https://patchlevel.dev/our-backward-compatibility-promise). -Also note that the `composer.lock` is always generated with the newest supported PHP version as this is the version our tools run in the CI. \ No newline at end of file +Also note that the `composer.lock` is always generated with the newest supported PHP version as this is the version our tools run in the CI. diff --git a/composer.json b/composer.json index 9e7bc517..7ad66228 100644 --- a/composer.json +++ b/composer.json @@ -2,12 +2,16 @@ "name": "patchlevel/hydrator", "type": "library", "license": "MIT", - "description": "Hydrator", + "description": "A library for seamless hydration of objects to arrays - and back again, optimized for developer experience and performance", "keywords": [ "hydrator", - "serializer" + "serializer", + "normalizer", + "denormalizer", + "object mapping", + "patchlevel" ], - "homepage": "https://github.com/patchlevel/hydrator", + "homepage": "https://patchlevel.dev/docs/hydrator/latest", "authors": [ { "name": "Daniel Badura", diff --git a/docs/caching.md b/docs/caching.md new file mode 100644 index 00000000..9ebb04b5 --- /dev/null +++ b/docs/caching.md @@ -0,0 +1,42 @@ +# Caching + +Before the hydrator can process a class, it builds metadata for it: the +properties, their field names and the resolved [normalizers](normalizer.md). +This happens once per class and process and is cheap, but with many classes +(or in short-lived processes) you can cache the metadata with any PSR-6 or +PSR-16 cache to skip the reflection entirely. + +## Configure the cache + +Pass the cache to the builder with `setCache`. Both PSR-6 +(`Psr\Cache\CacheItemPoolInterface`) and PSR-16 (`Psr\SimpleCache\CacheInterface`) +implementations are accepted. + +```php +use Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\StackHydratorBuilder; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; + +$hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->setCache(new FilesystemAdapter()) + ->build(); +``` +:::note +Internally the builder wraps the metadata factory in a `Psr6MetadataFactory` or +`Psr16MetadataFactory` from the `Patchlevel\Hydrator\Metadata` namespace. You can +also use these decorators directly if you construct the `StackHydrator` by hand. +::: + +:::warning +The cached metadata contains the resolved normalizer instances and everything +metadata enrichers stored in `extras`, so all of it must be serializable. Clear +the cache when you change attributes, property types or normalizers, stale +metadata leads to confusing results. +::: + +## Learn more + +* [How metadata enrichers add data to the metadata](extensions.md) +* [How normalizers are resolved](normalizer.md) +* [How to use the hydrator](hydrator.md) diff --git a/docs/cryptography.md b/docs/cryptography.md new file mode 100644 index 00000000..c2093c77 --- /dev/null +++ b/docs/cryptography.md @@ -0,0 +1,205 @@ +# Cryptography + +The cryptography extension can encrypt and decrypt sensitive data, e.g. +personal data of customers. For each subject (e.g. a person) a separate cipher +key is created and used to encrypt the marked fields. If the key is deleted, +the data becomes unreadable. This pattern is known as crypto-shredding and +makes "forgetting" a person possible even in immutable storage. +:::experimental +The cryptography extension is experimental and may change in a minor release. +::: + +## Setup + +Register the `CryptographyExtension` on the builder and pass it a +`Cryptographer`. The `BaseCryptographer` with the openssl cipher is the default +choice; it needs a [cipher key store](#cipher-key-store) to keep the keys. + +```php +use Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\Extension\Cryptography\BaseCryptographer; +use Patchlevel\Hydrator\Extension\Cryptography\CryptographyExtension; +use Patchlevel\Hydrator\Extension\Cryptography\Store\InMemoryCipherKeyStore; +use Patchlevel\Hydrator\StackHydratorBuilder; + +$cipherKeyStore = new InMemoryCipherKeyStore(); + +$hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new CryptographyExtension(BaseCryptographer::createWithOpenssl($cipherKeyStore))) + ->build(); +``` + +## DataSubjectId + +First you need to define which field identifies the subject the data belongs +to. The cipher key is created and looked up per subject id. + +```php +use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId; +use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData; + +final class EmailChanged +{ + public function __construct( + #[DataSubjectId] + public readonly string $profileId, + #[SensitiveData] + public readonly string|null $email, + ) { + } +} +``` +:::warning +The `DataSubjectId` must be a string, you can use a [normalizer](normalizer.md) +to convert a value object to a string. The subject id itself cannot be sensitive +data. +::: + +You can also use multiple subject ids in one class by naming them and +referencing the name from the sensitive fields. The default name is `default`. + +```php +use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId; +use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData; + +final class ProfilesMerged +{ + public function __construct( + #[DataSubjectId(name: 'source')] + public readonly string $sourceProfileId, + #[SensitiveData(subjectIdName: 'source')] + public readonly string|null $sourceEmail, + #[DataSubjectId(name: 'target')] + public readonly string $targetProfileId, + #[SensitiveData(subjectIdName: 'target')] + public readonly string|null $targetEmail, + ) { + } +} +``` + +## Fallback values + +If the data could not be decrypted, because the key has been removed, a +fallback value is inserted. The default fallback is `null`. You can change this +with the `fallback` parameter: + +```php +use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId; +use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData; + +final class ProfileCreated +{ + public function __construct( + #[DataSubjectId] + public readonly string $profileId, + #[SensitiveData(fallback: 'unknown')] + public readonly string $name, + ) { + } +} +``` + +You can also use a callable as a fallback. It receives the subject id: + +```php +use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId; +use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData; + +final class ProfileCreated +{ + public function __construct( + #[DataSubjectId] + public readonly string $profileId, + #[SensitiveData(fallback: 'deleted profile')] + public readonly string $name, + #[SensitiveData(fallbackCallable: [self::class, 'anonymizedEmail'])] + public readonly string $email, + ) { + } + + public static function anonymizedEmail(string $subjectId): string + { + return sprintf('%s@anonymized.example', $subjectId); + } +} +``` +:::note +`fallback` and `fallbackCallable` are mutually exclusive, setting both throws +an exception. +::: + +## Cipher Key Store + +The cipher keys must be stored somewhere. For testing purposes there is an +in-memory implementation: + +```php +use Patchlevel\Hydrator\Extension\Cryptography\Store\InMemoryCipherKeyStore; + +$cipherKeyStore = new InMemoryCipherKeyStore(); +``` + +For production you have to implement the `CipherKeyStore` interface yourself, +backed by a database or a key management service, because only you know where +the keys should live: + +```php +namespace Patchlevel\Hydrator\Extension\Cryptography\Store; + +use Patchlevel\Hydrator\Extension\Cryptography\Cipher\CipherKey; + +interface CipherKeyStore +{ + /** @throws CipherKeyNotExists */ + public function currentKeyFor(string $subjectId): CipherKey; + + /** @throws CipherKeyNotExists */ + public function get(string $id): CipherKey; + + public function store(CipherKey $key): void; + + public function remove(string $id): void; + + public function removeWithSubjectId(string $subjectId): void; +} +``` + +To avoid hitting your key storage for every operation, you can wrap the store +in one of the cache decorators: + +```php +use Patchlevel\Hydrator\Extension\Cryptography\Store\Psr6CacheStoreDecorator; +use Patchlevel\Hydrator\Extension\Cryptography\Store\Psr16CacheStoreDecorator; + +$cipherKeyStore = new Psr6CacheStoreDecorator($myDatabaseStore, $psr6CachePool); +// or +$cipherKeyStore = new Psr16CacheStoreDecorator($myDatabaseStore, $psr16Cache); +``` + +## Remove personal data + +To remove personal data, you only need to remove the keys for the subject from +the store. All encrypted fields of that subject then resolve to their +[fallback values](#fallback-values). + +```php +$cipherKeyStore->removeWithSubjectId('profile-1'); +``` +:::danger +Removing a cipher key is irreversible. The encrypted data can never be decrypted +again, that is the point of crypto-shredding, but make sure it is what you want. +::: + +:::tip +Cryptography is very expensive in terms of performance. You can combine it with +[lazy objects](lazy.md) so the data is only decrypted when the object is +actually accessed. +::: + +## Learn more + +* [How to hydrate objects lazily](lazy.md) +* [How extensions work](extensions.md) +* [How to use the hydrator](hydrator.md) diff --git a/docs/extensions.md b/docs/extensions.md new file mode 100644 index 00000000..22b94bf7 --- /dev/null +++ b/docs/extensions.md @@ -0,0 +1,129 @@ +# Extensions + +The `StackHydrator` is assembled from small building blocks: middlewares that +wrap the hydration process, [guessers](guesser.md) that resolve normalizers and +metadata enrichers that add information to the class metadata. An extension +bundles such building blocks so they can be registered with a single call. + +## Using extensions + +Extensions are registered on the `StackHydratorBuilder` with `useExtension`. +The `CoreExtension` provides the default behaviour and should (almost) always +be there. + +```php +use Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\Extension\Lifecycle\LifecycleExtension; +use Patchlevel\Hydrator\StackHydratorBuilder; + +$hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new LifecycleExtension()) + ->build(); +``` + +## Built-in extensions + +The library ships with three extensions out of the box: + +| Extension | Purpose | +| --- | --- | +| `CoreExtension` | The default behaviour, the `TransformMiddleware` and the `BuiltInGuesser`. | +| `LifecycleExtension` | [Lifecycle hooks](lifecycle-hooks.md), run code before and after the extract and hydrate process. | +| `CryptographyExtension` | [Cryptography](cryptography.md), encrypt and decrypt sensitive data with crypto-shredding. | + +## Middleware + +A middleware wraps the hydration and extraction process, similar to HTTP +middlewares. It can modify the incoming data, the outgoing array or the object +itself, and then delegates to the next middleware on the stack. The innermost +middleware is the `TransformMiddleware`, which does the actual property mapping. + +```php +use Patchlevel\Hydrator\Metadata\ClassMetadata; +use Patchlevel\Hydrator\Middleware\Middleware; +use Patchlevel\Hydrator\Middleware\Stack; + +final class RemoveNullValuesMiddleware implements Middleware +{ + public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object + { + return $stack->next()->hydrate($metadata, $data, $context, $stack); + } + + public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array + { + $data = $stack->next()->extract($metadata, $object, $context, $stack); + + return array_filter($data, static fn (mixed $value) => $value !== null); + } +} +``` + +Middlewares are added with a priority, higher priorities run first (outermost). +The `TransformMiddleware` from the `CoreExtension` has priority `-64`, so it +always runs last. + +```php +$builder->addMiddleware(new RemoveNullValuesMiddleware(), 0); +``` + +## Metadata enricher + +A metadata enricher runs once per class when the metadata is created. It can +inspect the class and attach extra information to `ClassMetadata::$extras`, +which a middleware can later read. This keeps expensive reflection out of the +hot path. + +```php +use Patchlevel\Hydrator\Metadata\ClassMetadata; +use Patchlevel\Hydrator\Metadata\MetadataEnricher; + +final class AuditMetadataEnricher implements MetadataEnricher +{ + public function enrich(ClassMetadata $classMetadata): void + { + $attributes = $classMetadata->reflection->getAttributes(Audited::class); + + if ($attributes === []) { + return; + } + + $classMetadata->extras[Audited::class] = true; + } +} +``` + +```php +$builder->addMetadataEnricher(new AuditMetadataEnricher()); +``` +:::note +Metadata enrichers also accept a priority. Since the metadata (including the +extras) can be [cached](caching.md), everything you store in `extras` must be +serializable. +::: + +## Writing your own extension + +An extension implements the `Extension` interface and configures the builder. +This is the way to package a middleware together with its metadata enricher. + +```php +use Patchlevel\Hydrator\Extension; +use Patchlevel\Hydrator\StackHydratorBuilder; + +final class AuditExtension implements Extension +{ + public function configure(StackHydratorBuilder $builder): void + { + $builder->addMetadataEnricher(new AuditMetadataEnricher()); + $builder->addMiddleware(new AuditMiddleware()); + } +} +``` + +## Learn more + +* [How to run code before extract and after hydrate](lifecycle-hooks.md) +* [How to encrypt sensitive data](cryptography.md) +* [How to cache the metadata](caching.md) diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 00000000..5b63e7f1 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,138 @@ +# Getting Started + +In this guide you build a small profile domain and use the hydrator to convert +its objects into plain arrays and back. Everything you see here works without +any configuration, the hydrator figures out the normalizers on its own. + +## Define the classes + +We start with a backed enum for the role, a `Skill` value object and a +`ProfileCreated` event that combines them. All classes are `final`, `readonly` +and use constructor property promotion, the hydrator supports all of it. + +```php +enum Role: string +{ + case Admin = 'admin'; + case Member = 'member'; +} + +final readonly class Skill +{ + public function __construct( + public string $name, + public int $level, + ) { + } +} + +final readonly class ProfileCreated +{ + /** @param list $skills */ + public function __construct( + public int $id, + public string $name, + public Role $role, + public array $skills, + public DateTimeImmutable $createdAt, + ) { + } +} +``` +:::note +The `@param list` docblock is what tells the hydrator the element type of +the collection. How types are resolved into normalizers is explained on the +[normalizer](normalizer.md) page. +::: + +## Create the hydrator + +The recommended way to create a hydrator is the `StackHydratorBuilder` together +with the `CoreExtension`, which registers the default middleware and the +built-in normalizer guesser. + +```php +use Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\StackHydratorBuilder; + +$hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->build(); +``` +:::note +The builder is also the place to add [extensions](extensions.md), custom +[guessers](guesser.md) and a [metadata cache](caching.md). +::: + +## Extract data + +To convert an object into a serializable array, use the `extract` method. + +```php +$event = new ProfileCreated( + 1, + 'patchlevel', + Role::Admin, + [new Skill('php', 10), new Skill('event-sourcing', 10)], + new DateTimeImmutable('2023-10-01 12:00:00'), +); + +$data = $hydrator->extract($event); +``` + +The result looks like this: + +```php +[ + 'id' => 1, + 'name' => 'patchlevel', + 'role' => 'admin', + 'skills' => [ + ['name' => 'php', 'level' => 10], + ['name' => 'event-sourcing', 'level' => 10], + ], + 'createdAt' => '2023-10-01T12:00:00+00:00', +] +``` + +You can now turn this array into JSON with `json_encode` and store it anywhere. + +## Hydrate the object back + +The process can be reversed with the `hydrate` method. You pass the class that +should be created and the data that should be written into it. + +```php +$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', + ], +); +``` +:::warning +The constructor is **not** called during hydration. The properties are written +directly, so constructor validation does not run. You can find more details on the +[hydrator](hydrator.md) page. +::: + +## Result + +You can now round-trip arbitrarily nested objects: enums, date types, +collections and nested value objects are handled automatically. When the +automatic resolution is not enough, you attach a [normalizer](normalizer.md) +to the property or class, and that is usually all the configuration you ever need. + +## Learn more + +* [How to use the hydrator in depth](hydrator.md) +* [How normalizers are resolved and which ones exist](normalizer.md) +* [How to rename or ignore fields](hydrator.md) +* [How to hydrate objects lazily](lazy.md) diff --git a/docs/guesser.md b/docs/guesser.md new file mode 100644 index 00000000..d164b1f3 --- /dev/null +++ b/docs/guesser.md @@ -0,0 +1,81 @@ +# Guesser + +When a property is an object and no [normalizer](normalizer.md) attribute is +found on the property or class, the hydrator asks its guessers which normalizer +to use. Guessers are the right tool when you can't put an attribute on the +class itself, for example for third-party classes. + +## Built-in guesser + +The `BuiltInGuesser` is registered by the `CoreExtension`. It resolves backed +enums to the `EnumNormalizer`, the date types (`DateTimeImmutable`, `DateTime`, +`DateTimeZone`, `DateInterval`) to their normalizers and falls back to the +`ObjectNormalizer` for everything else. + +## Custom guesser + +A guesser implements the `Guesser` interface. It receives the resolved object +type and returns a normalizer or `null` if it is not responsible. + +```php +use Patchlevel\Hydrator\Guesser\Guesser; +use Patchlevel\Hydrator\Normalizer\Normalizer; +use Symfony\Component\TypeInfo\Type\ObjectType; + +final class NameGuesser implements Guesser +{ + public function guess(ObjectType $type): Normalizer|null + { + return match ($type->getClassName()) { + Name::class => new NameNormalizer(), + default => null, + }; + } +} +``` + +To use the guesser, add it to the builder: + +```php +use Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\StackHydratorBuilder; + +$hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->addGuesser(new NameGuesser()) + ->build(); +``` +:::note +The guessers are queried in order of their priority, and the first match wins. +The built-in guesser is registered with priority `-64`, so your own guessers run +before the fallback to the `ObjectNormalizer`. +::: + +## Mapped guesser + +For the common case of a simple class-to-normalizer mapping, you don't need to +write your own guesser class, use the `MappedGuesser`: + +```php +use Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\Guesser\MappedGuesser; +use Patchlevel\Hydrator\StackHydratorBuilder; + +$hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->addGuesser(new MappedGuesser([ + Name::class => NameNormalizer::class, + Email::class => EmailNormalizer::class, + ])) + ->build(); +``` +:::note +The `MappedGuesser` instantiates the normalizer class without arguments, so the +normalizer must have a constructor without required parameters. +::: + +## Learn more + +* [How normalizers are resolved](normalizer.md) +* [How to configure the builder](extensions.md) +* [How to use the hydrator](hydrator.md) diff --git a/docs/hydrator.md b/docs/hydrator.md new file mode 100644 index 00000000..a864f7fc --- /dev/null +++ b/docs/hydrator.md @@ -0,0 +1,194 @@ +# Hydrator + +The hydrator converts objects into plain arrays (`extract`) and arrays back into +objects (`hydrate`). The default implementation is the `StackHydrator`, which +runs both operations through a stack of [middlewares](extensions.md) and +resolves [normalizers](normalizer.md) from metadata. + +## Create the hydrator + +The recommended way is the `StackHydratorBuilder` with the `CoreExtension`. +The `CoreExtension` registers the `TransformMiddleware`, which does the actual +property mapping, and the `BuiltInGuesser`, which picks normalizers for enums, +date types and nested objects. + +```php +use Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\StackHydratorBuilder; + +$hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->build(); +``` + +If you don't need any extensions, you can also instantiate the `StackHydrator` +directly, it defaults to the same middleware and guesser: + +```php +use Patchlevel\Hydrator\StackHydrator; + +$hydrator = new StackHydrator(); +``` +:::tip +Use the builder as soon as you want [extensions](extensions.md), custom +[guessers](guesser.md), [lazy objects by default](lazy.md) or a +[metadata cache](caching.md). +::: + +## Extract data + +To convert objects into serializable arrays, use the `extract` method. + +```php +use DateTimeImmutable; + +$event = new ProfileCreated( + 1, + 'patchlevel', + Role::Admin, + [new Skill('php', 10), new Skill('event-sourcing', 10)], + new DateTimeImmutable('2023-10-01 12:00:00'), +); + +$data = $hydrator->extract($event); +``` + +The result is an array of scalars and nested arrays that can be passed straight +to `json_encode`: + +```php +[ + 'id' => 1, + 'name' => 'patchlevel', + 'role' => 'admin', + 'skills' => [ + ['name' => 'php', 'level' => 10], + ['name' => 'event-sourcing', 'level' => 10], + ], + 'createdAt' => '2023-10-01T12:00:00+00:00', +] +``` + +## Hydrate objects + +The reverse direction is the `hydrate` method. You specify the class that should +be created and the data that should be written into it. + +```php +$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', + ], +); +``` +:::warning +The constructor is **not** called! The object is created without invoking the +constructor and the properties are written directly. Validation logic in the +constructor does not run during hydration. +::: + +If a field is missing in the data and the property is a promoted constructor +parameter with a default value, the default value is used. + +## Object to populate + +If you want to hydrate an object that already exists, you can pass the object +to populate via the context. This is useful if you want to update an existing object. + +```php +use Patchlevel\Hydrator\Hydrator; + +$profile = new Profile(); + +$profile = $hydrator->hydrate( + Profile::class, + ['name' => 'patchlevel'], + [Hydrator::OBJECT_TO_POPULATE => $profile], +); +``` + +## Rename fields + +By default, the property name is used to name the field in the extracted +result. This can be customized with the `NormalizedName` attribute. + +```php +use Patchlevel\Hydrator\Attribute\NormalizedName; + +final class Profile +{ + #[NormalizedName('profile_name')] + public string $name; +} +``` + +The extracted result then looks like this: + +```php +[ + 'profile_name' => 'patchlevel', +] +``` +:::tip +You can rename a property without a backwards compatibility break in your stored +data by keeping the old serialized name with `NormalizedName`. +::: + +## Ignore properties + +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. + +```php +use Patchlevel\Hydrator\Attribute\Ignore; + +final readonly class ProfileCreated +{ + public function __construct( + public string $id, + public string $name, + #[Ignore] + public string $internalState, + ) { + } +} +``` +:::warning +An ignored property is never written during hydration. Make sure it has a default +value or is set by a [lifecycle hook](lifecycle-hooks.md), otherwise it stays +uninitialized. +::: + +## Error handling + +Everything the library throws implements the `HydratorException` interface, so a +single catch block is enough at the boundary. + +```php +use Patchlevel\Hydrator\HydratorException; + +try { + $event = $hydrator->hydrate(ProfileCreated::class, $data); +} catch (HydratorException $e) { + // invalid data, unsupported class, type mismatch, ... +} +``` +:::note +The most common exceptions are `ClassNotSupported` if the class does not exist, +`DenormalizationFailure` if a normalizer rejects a value and `TypeMismatch` if a +value does not fit the property type. +::: + +## Learn more + +* [How normalizers convert complex types](normalizer.md) +* [How to hydrate objects lazily](lazy.md) +* [How to hook into the hydration process](extensions.md) diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..84e83ac0 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,35 @@ +# Hydrator + +This library enables seamless hydration of objects to arrays - and back again. +It is optimized for both developer experience and performance and works with `final`, +`readonly` classes, constructor property promotion and deeply nested structures. + +Hydration is handled through [normalizers](normalizer.md), especially for complex data types. +The library automatically determines the appropriate normalizer based on the property type +and attributes, so in most cases no manual configuration is needed. +And if customization is required, it can be done easily using attributes. + +## Features + +* Extract objects to arrays and [hydrate](hydrator.md) them back, without calling the constructor. +* Automatic [normalizer](normalizer.md) resolution for enums, date types, collections, array shapes and nested objects. +* Rename or exclude fields with [attributes](hydrator.md). +* [Lazy hydration](lazy.md) of objects with PHP 8.4 lazy proxies. +* Pluggable [guessers](guesser.md) to pick normalizers for your own value objects. +* [Extensions](extensions.md) with middlewares and metadata enrichers to hook into the process. +* [Lifecycle hooks](lifecycle-hooks.md) before extracting and after hydrating. +* Encrypt and decrypt sensitive data with the [cryptography](cryptography.md) extension (crypto-shredding). +* [Cache](caching.md) the metadata with any PSR-6 or PSR-16 cache. + +## Installation + +```bash +composer require patchlevel/hydrator +``` + +## Integration + +* [Event Sourcing](https://github.com/patchlevel/event-sourcing) - the hydrator powers the storage and retrieval of thousands of events and aggregates. +:::tip +New here? Start with the [getting started guide](getting-started.md) and build your first hydrator in a few minutes. +::: diff --git a/docs/lazy.md b/docs/lazy.md new file mode 100644 index 00000000..e683fb1f --- /dev/null +++ b/docs/lazy.md @@ -0,0 +1,71 @@ +# Lazy Objects + +Since PHP 8.4, it is possible to hydrate objects lazily. The hydrator then +returns a lazy proxy and the actual hydration happens only when the object is +accessed for the first time. This saves work when you hydrate many objects but +only touch a few of them. + +## Enable lazy hydration per class + +You can define for each class whether you want it to be lazy by using the +`Lazy` attribute. + +```php +use Patchlevel\Hydrator\Attribute\Lazy; + +#[Lazy] +final readonly class ProfileCreated +{ + public function __construct( + public string $id, + public string $name, + ) { + } +} +``` +:::note +If you are using a PHP version older than 8.4, the attribute is ignored and the +object is hydrated eagerly. +::: + +## Enable lazy hydration by default + +Instead of marking every class, you can make lazy hydration the default when +building the hydrator. + +```php +use Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\StackHydratorBuilder; + +$hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->enableDefaultLazy() + ->build(); +``` + +Single classes can then opt out again with the attribute: + +```php +use Patchlevel\Hydrator\Attribute\Lazy; + +#[Lazy(false)] +final readonly class ProfileCreated +{ + public function __construct( + public string $id, + public string $name, + ) { + } +} +``` +:::tip +[Cryptography](cryptography.md) is very expensive in terms of performance. You can +combine it with lazy objects so the data is only decrypted when you actually +access the object. +::: + +## Learn more + +* [How to use the hydrator](hydrator.md) +* [How to configure the builder](extensions.md) +* [How to encrypt sensitive data](cryptography.md) diff --git a/docs/lifecycle-hooks.md b/docs/lifecycle-hooks.md new file mode 100644 index 00000000..dd701e3f --- /dev/null +++ b/docs/lifecycle-hooks.md @@ -0,0 +1,104 @@ +# Lifecycle Hooks + +Sometimes you need to do something before or after the extract and hydrate +process, for example migrate old data structures, compute derived state or clean up the +result. For this, the `LifecycleExtension` provides four method attributes: +`PreHydrate`, `PostHydrate`, `PreExtract` and `PostExtract`. + +## Setup + +Register the `LifecycleExtension` on the builder: + +```php +use Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\Extension\Lifecycle\LifecycleExtension; +use Patchlevel\Hydrator\StackHydratorBuilder; + +$hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new LifecycleExtension()) + ->build(); +``` + +## Hooks + +The hooks are **static** methods on the class being hydrated. The data hooks +(`PreHydrate`, `PostExtract`) receive the data array and must return the +(modified) array; the object hooks (`PostHydrate`, `PreExtract`) receive the +object instance. + +```php +use Patchlevel\Hydrator\Extension\Lifecycle\Attribute\PostExtract; +use Patchlevel\Hydrator\Extension\Lifecycle\Attribute\PostHydrate; +use Patchlevel\Hydrator\Extension\Lifecycle\Attribute\PreExtract; +use Patchlevel\Hydrator\Extension\Lifecycle\Attribute\PreHydrate; + +final class Profile +{ + public function __construct( + public string $name, + ) { + } + + /** + * @param array $data + * @param array $context + * + * @return array + */ + #[PreHydrate] + public static function migrateOldData(array $data, array $context): array + { + // rename a legacy field before the object is hydrated + if (isset($data['profile_name'])) { + $data['name'] = $data['profile_name']; + unset($data['profile_name']); + } + + return $data; + } + + /** @param array $context */ + #[PostHydrate] + public static function afterHydrate(object $object, array $context): void + { + // do something with the freshly hydrated object + } + + /** @param array $context */ + #[PreExtract] + public static function beforeExtract(object $object, array $context): void + { + // do something with the object before it is extracted + } + + /** + * @param array $data + * @param array $context + * + * @return array + */ + #[PostExtract] + public static function cleanupData(array $data, array $context): array + { + // adjust the extracted array before it is returned + return $data; + } +} +``` +:::warning +The hook methods must be `static`, otherwise a `LogicException` is thrown when +the metadata is created. The object hooks receive the instance as their first +parameter instead of using `$this`. +::: + +:::tip +`PreHydrate` is a good place for schema migrations: old stored data can be +upgraded to the current class structure without touching the persisted payload. +::: + +## Learn more + +* [How extensions and middlewares work](extensions.md) +* [How to use the hydrator](hydrator.md) +* [How to rename fields without hooks](hydrator.md) diff --git a/docs/normalizer.md b/docs/normalizer.md new file mode 100644 index 00000000..325befdb --- /dev/null +++ b/docs/normalizer.md @@ -0,0 +1,375 @@ +# Normalizer + +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. + +## How normalizers are resolved + +For every property, the normalizer is determined in this order: + +1. Does the property have a normalizer as an attribute? Use this. +2. Otherwise, the type of the property is determined: + 1. If it is an array shape, the `ArrayShapeNormalizer` is used (recursive). + 2. If it is a collection, the `ArrayNormalizer` is used (recursive). + 3. If it is an object, a normalizer attribute is searched on the class, its parents and interfaces. + 4. If none is found, the [guessers](guesser.md) are asked. The built-in guesser handles enums and date types and falls back to the `ObjectNormalizer`. + +The normalizer is only determined once per class because it is cached in the +[metadata](caching.md). + +## Array + +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. + +```php +final readonly class ProfileCreated +{ + /** @param list $skills */ + public function __construct( + public array $skills, + ) { + } +} +``` + +You can also set the `ArrayNormalizer` explicitly and pass it the normalizer +for the elements: + +```php +use DateTimeImmutable; +use Patchlevel\Hydrator\Normalizer\ArrayNormalizer; +use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer; + +final class Profile +{ + /** @var list */ + #[ArrayNormalizer(new DateTimeImmutableNormalizer())] + public array $loginDates; +} +``` +:::note +The keys of the array are kept. +::: + +## ArrayShape + +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. + +```php +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; +} +``` + +## DateTimeImmutable + +With the `DateTimeImmutableNormalizer` you can convert `DateTimeImmutable` +objects to a string and back again. It is applied automatically to +`DateTimeImmutable` properties. + +```php +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`. + +```php +use DateTimeImmutable; +use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer; + +final class Profile +{ + #[DateTimeImmutableNormalizer(format: DateTimeImmutable::RFC3339_EXTENDED)] + public DateTimeImmutable $createdAt; +} +``` +:::note +You can read about how the format is structured in the [php docs](https://www.php.net/manual/en/datetime.format.php). +::: + +## DateTime + +The `DateTimeNormalizer` works exactly like the `DateTimeImmutableNormalizer`, +only for `DateTime` objects. The default format is `DateTime::ATOM`. + +```php +use DateTime; +use Patchlevel\Hydrator\Normalizer\DateTimeNormalizer; + +final class Profile +{ + #[DateTimeNormalizer(format: DateTime::RFC3339_EXTENDED)] + public DateTime $lastSeen; +} +``` + +## DateTimeZone + +To normalize a `DateTimeZone`, the `DateTimeZoneNormalizer` is used. + +```php +use DateTimeZone; +use Patchlevel\Hydrator\Normalizer\DateTimeZoneNormalizer; + +final class Profile +{ + #[DateTimeZoneNormalizer] + public DateTimeZone $timeZone; +} +``` + +## DateInterval + +A `DateInterval` is converted to its ISO 8601 duration string with the +`DateIntervalNormalizer`. The format can be customized. + +```php +use DateInterval; +use Patchlevel\Hydrator\Normalizer\DateIntervalNormalizer; + +final class Subscription +{ + #[DateIntervalNormalizer] + public DateInterval $renewEvery; +} +``` + +## Enum + +Backed enums are converted to their backing value. The enum class is inferred +from the property type, but can also be passed explicitly. + +```php +use Patchlevel\Hydrator\Normalizer\EnumNormalizer; + +final class Profile +{ + #[EnumNormalizer] + public Role $role; + + #[EnumNormalizer(Role::class)] + public mixed $explicitRole; +} +``` + +## Object + +If you have a complex object that you want to normalize, the `ObjectNormalizer` +is used. It runs the [hydrator](hydrator.md) 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. + +```php +use Patchlevel\Hydrator\Normalizer\ObjectNormalizer; + +final class Profile +{ + #[ObjectNormalizer] + public Address $address; + + #[ObjectNormalizer(Address::class)] + public object $untypedAddress; +} +``` +:::warning +Circular references are not supported and result in a `CircularReference` exception. +::: + +## ObjectMap + +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`). + +```php +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; +} +``` +:::note +Auto detection of the concrete type is not possible here. You have to specify the +map yourself. +::: + +## Inline + +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. + +```php +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; + } +} +``` +:::note +Closures in attributes are only possible since PHP 8.5, therefore this normalizer +can only be used as an attribute with PHP 8.5. +::: + +:::tip +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. +::: + +## Custom Normalizer + +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: + +```php +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: + +```php +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: + +```php +final class Profile +{ + #[NameNormalizer] + public Name $name; +} +``` + +## Define a 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. Every property typed with that +class then uses it automatically. + +```php +#[NameNormalizer] +final class Name +{ + // ... same as before +} +``` +:::tip +If you can't put an attribute on the class, for example for third-party classes, +write a [guesser](guesser.md) instead. +::: + +## Learn more + +* [How to guess normalizers for third-party classes](guesser.md) +* [How to rename or ignore fields](hydrator.md) +* [How to use the hydrator](hydrator.md) diff --git a/docs/project.json b/docs/project.json new file mode 100644 index 00000000..ff21e5bc --- /dev/null +++ b/docs/project.json @@ -0,0 +1,24 @@ +{ + "navigation": [ + { "title": "Introduction", "file": "index.md" }, + { "title": "Getting Started", "file": "getting-started.md" }, + { + "title": "Core", + "subEntries": [ + { "title": "Hydrator", "file": "hydrator.md" }, + { "title": "Normalizer", "file": "normalizer.md" }, + { "title": "Guesser", "file": "guesser.md" }, + { "title": "Lazy Objects", "file": "lazy.md" }, + { "title": "Extensions", "file": "extensions.md" }, + { "title": "Caching", "file": "caching.md" } + ] + }, + { + "title": "Extensions", + "subEntries": [ + { "title": "Lifecycle Hooks", "file": "lifecycle-hooks.md" }, + { "title": "Cryptography", "file": "cryptography.md" } + ] + } + ] +} From 17916cc7c6edb3619d2b55354cb37a0480674b8c Mon Sep 17 00:00:00 2001 From: David Badura Date: Sun, 14 Jun 2026 08:36:22 +0200 Subject: [PATCH 2/3] add upcasting docs and odm integration - document the upcaster extension on a new upcasting page - rename index.md to introduction.md per docs convention - add odm to the integration links - add deep links to the readme feature list --- README.md | 14 ++-- docs/cryptography.md | 1 + docs/extensions.md | 4 +- docs/{index.md => introduction.md} | 2 + docs/project.json | 5 +- docs/upcasting.md | 124 +++++++++++++++++++++++++++++ 6 files changed, 140 insertions(+), 10 deletions(-) rename docs/{index.md => introduction.md} (87%) create mode 100644 docs/upcasting.md diff --git a/README.md b/README.md index 600e27d6..e8a00655 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,14 @@ optimized for developer experience and performance." ## Features -* Extract objects to arrays and hydrate them back, without calling the constructor +* Extract objects to arrays and [hydrate](https://patchlevel.dev/docs/hydrator/latest/hydrator) them back, without calling the constructor * Works with `final`, `readonly` classes, property promotion and deeply nested structures -* Automatic normalizer resolution for enums, date types, collections, array shapes and objects -* Rename or exclude fields with attributes -* Lazy hydration of objects with PHP 8.4 lazy proxies -* Pluggable guessers and extensions to customize the process -* Safe usage of Personal Data with crypto-shredding -* Metadata caching with any PSR-6 or PSR-16 cache +* Automatic [normalizer](https://patchlevel.dev/docs/hydrator/latest/normalizer) resolution for enums, date types, collections, array shapes and objects +* [Rename or exclude](https://patchlevel.dev/docs/hydrator/latest/hydrator) fields with attributes +* [Lazy hydration](https://patchlevel.dev/docs/hydrator/latest/lazy) of objects with PHP 8.4 lazy proxies +* Pluggable [guessers](https://patchlevel.dev/docs/hydrator/latest/guesser) and [extensions](https://patchlevel.dev/docs/hydrator/latest/extensions) to customize the process +* Safe usage of Personal Data with [crypto-shredding](https://patchlevel.dev/docs/hydrator/latest/cryptography) +* [Metadata caching](https://patchlevel.dev/docs/hydrator/latest/caching) with any PSR-6 or PSR-16 cache * Developer experience oriented and fully typed * and much more... diff --git a/docs/cryptography.md b/docs/cryptography.md index c2093c77..611d597a 100644 --- a/docs/cryptography.md +++ b/docs/cryptography.md @@ -201,5 +201,6 @@ actually accessed. ## Learn more * [How to hydrate objects lazily](lazy.md) +* [How to reshape outdated stored data](upcasting.md) * [How extensions work](extensions.md) * [How to use the hydrator](hydrator.md) diff --git a/docs/extensions.md b/docs/extensions.md index 22b94bf7..520131b5 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -24,13 +24,14 @@ $hydrator = (new StackHydratorBuilder()) ## Built-in extensions -The library ships with three extensions out of the box: +The library ships with four extensions out of the box: | Extension | Purpose | | --- | --- | | `CoreExtension` | The default behaviour, the `TransformMiddleware` and the `BuiltInGuesser`. | | `LifecycleExtension` | [Lifecycle hooks](lifecycle-hooks.md), run code before and after the extract and hydrate process. | | `CryptographyExtension` | [Cryptography](cryptography.md), encrypt and decrypt sensitive data with crypto-shredding. | +| `UpcastExtension` | [Upcasting](upcasting.md), reshape outdated stored data while it is hydrated. | ## Middleware @@ -126,4 +127,5 @@ final class AuditExtension implements Extension * [How to run code before extract and after hydrate](lifecycle-hooks.md) * [How to encrypt sensitive data](cryptography.md) +* [How to reshape outdated stored data](upcasting.md) * [How to cache the metadata](caching.md) diff --git a/docs/index.md b/docs/introduction.md similarity index 87% rename from docs/index.md rename to docs/introduction.md index 84e83ac0..4b0b0491 100644 --- a/docs/index.md +++ b/docs/introduction.md @@ -19,6 +19,7 @@ And if customization is required, it can be done easily using attributes. * [Extensions](extensions.md) with middlewares and metadata enrichers to hook into the process. * [Lifecycle hooks](lifecycle-hooks.md) before extracting and after hydrating. * Encrypt and decrypt sensitive data with the [cryptography](cryptography.md) extension (crypto-shredding). +* [Upcast](upcasting.md) outdated stored data while it is hydrated. * [Cache](caching.md) the metadata with any PSR-6 or PSR-16 cache. ## Installation @@ -30,6 +31,7 @@ composer require patchlevel/hydrator ## Integration * [Event Sourcing](https://github.com/patchlevel/event-sourcing) - the hydrator powers the storage and retrieval of thousands of events and aggregates. +* [ODM](https://github.com/patchlevel/odm) - a lightweight object document mapper for MongoDB and PostgreSQL that builds on the hydrator for fast object mapping and full extension support. :::tip New here? Start with the [getting started guide](getting-started.md) and build your first hydrator in a few minutes. ::: diff --git a/docs/project.json b/docs/project.json index ff21e5bc..1f74881b 100644 --- a/docs/project.json +++ b/docs/project.json @@ -1,6 +1,6 @@ { "navigation": [ - { "title": "Introduction", "file": "index.md" }, + { "title": "Introduction", "file": "introduction.md" }, { "title": "Getting Started", "file": "getting-started.md" }, { "title": "Core", @@ -17,7 +17,8 @@ "title": "Extensions", "subEntries": [ { "title": "Lifecycle Hooks", "file": "lifecycle-hooks.md" }, - { "title": "Cryptography", "file": "cryptography.md" } + { "title": "Cryptography", "file": "cryptography.md" }, + { "title": "Upcasting", "file": "upcasting.md" } ] } ] diff --git a/docs/upcasting.md b/docs/upcasting.md new file mode 100644 index 00000000..7d4d1339 --- /dev/null +++ b/docs/upcasting.md @@ -0,0 +1,124 @@ +# Upcasting + +Over time the shape of your stored data drifts away from your classes: fields +get renamed, split or merged. Upcasting reshapes the stored array on the fly +while it is hydrated, so old payloads keep loading into your current classes +without a migration of the underlying storage. +:::experimental +The upcast extension is experimental and may change in a minor release. +::: + +## Setup + +Register the `UpcastExtension` on the builder and pass it a list of upcasters. +Each upcaster receives the raw data array and returns a reshaped array. + +```php +use Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\Extension\Upcast\CallbackUpcaster; +use Patchlevel\Hydrator\Extension\Upcast\UpcastExtension; +use Patchlevel\Hydrator\StackHydratorBuilder; + +$hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new UpcastExtension( + beforeTransform: [ + CallbackUpcaster::forClass( + ProfileCreated::class, + static function (array $data): array { + $data['name'] = $data['firstName'] . ' ' . $data['lastName']; + unset($data['firstName'], $data['lastName']); + + return $data; + }, + ), + ], + )) + ->build(); +``` +:::note +Upcasting only runs during [hydration](hydrator.md). Extraction always writes +the current shape, so once an object has been re-extracted its stored payload is +up to date. +::: + +## Writing an upcaster + +An upcaster implements the `Upcaster` interface. It receives the +[class metadata](hydrator.md), the data array and the context, and returns the +reshaped data. Because every registered upcaster runs for every class, check the +metadata and leave data you do not care about untouched. + +```php +use Patchlevel\Hydrator\Extension\Upcast\Upcaster; +use Patchlevel\Hydrator\Metadata\ClassMetadata; + +final class RenameEmailUpcaster implements Upcaster +{ + public function upcast(ClassMetadata $metadata, array $data, array $context): array + { + if ($metadata->className !== ProfileCreated::class) { + return $data; + } + + $data['email'] = $data['mail']; + unset($data['mail']); + + return $data; + } +} +``` + +For the common case of a single class and a closure, use the +`CallbackUpcaster`. It compares the class name for you and only invokes the +callback for a match. The callback receives the data and the context: + +```php +use Patchlevel\Hydrator\Extension\Upcast\CallbackUpcaster; + +$upcaster = CallbackUpcaster::forClass( + ProfileCreated::class, + static function (array $data, array $context): array { + $data['email'] = $data['mail']; + unset($data['mail']); + + return $data; + }, +); +``` + +## When upcasters run + +The hydrator decodes the stored payload in stages: first it is read as raw +values, then [normalizers](normalizer.md) decode each field, and finally the +object is built. The `UpcastExtension` can hook into two of these stages, and +you pass your upcasters to the matching argument. + +| Argument | Runs | Works on | +| --- | --- | --- | +| `beforeEncoding` | before the values are decoded | the raw stored values (strings, ints, ...) | +| `beforeTransform` | after decoding, right before the object is built | the decoded values (enums, dates, value objects, ...) | + +Use `beforeEncoding` when you rename or restructure fields whose raw form is +enough, and `beforeTransform` when you need the already decoded values. + +```php +use Patchlevel\Hydrator\Extension\Upcast\UpcastExtension; + +$extension = new UpcastExtension( + beforeEncoding: [$renameFieldUpcaster], + beforeTransform: [$mergeNameUpcaster], +); +``` +:::warning +The [cryptography](cryptography.md) extension decrypts values during the +decoding stage. A `beforeEncoding` upcaster therefore still sees the encrypted +values, while a `beforeTransform` upcaster sees the decrypted ones. Pick the +stage that matches the data you need. +::: + +## Learn more + +* [How to write your own extension](extensions.md) +* [How to decode values with normalizers](normalizer.md) +* [How to encrypt sensitive data](cryptography.md) From a21267e368dd2c387a4db66c02448da0c746aea2 Mon Sep 17 00:00:00 2001 From: David Badura Date: Sun, 14 Jun 2026 08:38:31 +0200 Subject: [PATCH 3/3] remove experimental annotation from upcaster --- docs/upcasting.md | 3 --- src/Extension/Upcast/CallbackUpcaster.php | 1 - src/Extension/Upcast/UpcastExtension.php | 1 - src/Extension/Upcast/UpcastMiddleware.php | 1 - src/Extension/Upcast/Upcaster.php | 1 - 5 files changed, 7 deletions(-) diff --git a/docs/upcasting.md b/docs/upcasting.md index 7d4d1339..0fb023c6 100644 --- a/docs/upcasting.md +++ b/docs/upcasting.md @@ -4,9 +4,6 @@ Over time the shape of your stored data drifts away from your classes: fields get renamed, split or merged. Upcasting reshapes the stored array on the fly while it is hydrated, so old payloads keep loading into your current classes without a migration of the underlying storage. -:::experimental -The upcast extension is experimental and may change in a minor release. -::: ## Setup diff --git a/src/Extension/Upcast/CallbackUpcaster.php b/src/Extension/Upcast/CallbackUpcaster.php index fee20b26..1821efe5 100644 --- a/src/Extension/Upcast/CallbackUpcaster.php +++ b/src/Extension/Upcast/CallbackUpcaster.php @@ -7,7 +7,6 @@ use Closure; use Patchlevel\Hydrator\Metadata\ClassMetadata; -/** @experimental */ final class CallbackUpcaster implements Upcaster { /** @var Closure(array, array): array */ diff --git a/src/Extension/Upcast/UpcastExtension.php b/src/Extension/Upcast/UpcastExtension.php index 3dfbc8e5..0e99d0d8 100644 --- a/src/Extension/Upcast/UpcastExtension.php +++ b/src/Extension/Upcast/UpcastExtension.php @@ -7,7 +7,6 @@ use Patchlevel\Hydrator\Extension; use Patchlevel\Hydrator\StackHydratorBuilder; -/** @experimental */ final readonly class UpcastExtension implements Extension { /** diff --git a/src/Extension/Upcast/UpcastMiddleware.php b/src/Extension/Upcast/UpcastMiddleware.php index 3d0692fb..58ada4d0 100644 --- a/src/Extension/Upcast/UpcastMiddleware.php +++ b/src/Extension/Upcast/UpcastMiddleware.php @@ -8,7 +8,6 @@ use Patchlevel\Hydrator\Middleware\Middleware; use Patchlevel\Hydrator\Middleware\Stack; -/** @experimental */ final readonly class UpcastMiddleware implements Middleware { /** @param list $upcasters */ diff --git a/src/Extension/Upcast/Upcaster.php b/src/Extension/Upcast/Upcaster.php index bed39a78..53c6ff02 100644 --- a/src/Extension/Upcast/Upcaster.php +++ b/src/Extension/Upcast/Upcaster.php @@ -6,7 +6,6 @@ use Patchlevel\Hydrator\Metadata\ClassMetadata; -/** @experimental */ interface Upcaster { /**