Upgrade from v1 to v2

Hydrator

The hydrator was rebuilt on top of a middleware stack. The old MetadataHydrator and its event dispatcher based extension points are gone, the new entry points are the StackHydrator and the StackHydratorBuilder.

MetadataHydrator

MetadataHydrator has been removed in favor of StackHydrator. The static create() factory is gone, build the hydrator with the StackHydratorBuilder and register the CoreExtension, which sets up the property mapping and the built-in guesser.

before:

use Patchlevel\Hydrator\MetadataHydrator;

$hydrator = MetadataHydrator::create();

after:

use Patchlevel\Hydrator\CoreExtension;
use Patchlevel\Hydrator\StackHydratorBuilder;

$hydrator = (new StackHydratorBuilder())
    ->useExtension(new CoreExtension())
    ->build();

If you do not need any extensions, you can also instantiate the StackHydrator directly, it defaults to the same middleware and guesser.

before:

use Patchlevel\Hydrator\MetadataHydrator;

$hydrator = new MetadataHydrator();

after:

use Patchlevel\Hydrator\StackHydrator;

$hydrator = new StackHydrator();

Custom guessers

The guessers are no longer passed to a static factory, register them on the builder instead.

before:

use Patchlevel\Hydrator\MetadataHydrator;

$hydrator = MetadataHydrator::create([new NameGuesser()]);

after:

use Patchlevel\Hydrator\CoreExtension;
use Patchlevel\Hydrator\StackHydratorBuilder;

$hydrator = (new StackHydratorBuilder())
    ->useExtension(new CoreExtension())
    ->addGuesser(new NameGuesser())
    ->build();

Default lazy objects

The defaultLazy constructor flag moved to enableDefaultLazy() on the builder.

before:

use Patchlevel\Hydrator\MetadataHydrator;

$hydrator = new MetadataHydrator(defaultLazy: true);

after:

use Patchlevel\Hydrator\CoreExtension;
use Patchlevel\Hydrator\StackHydratorBuilder;

$hydrator = (new StackHydratorBuilder())
    ->useExtension(new CoreExtension())
    ->enableDefaultLazy()
    ->build();

HydratorWithContext

The HydratorWithContext interface has been removed, its methods were merged into the Hydrator interface. hydrate and extract now always take a $context array, and the OBJECT_TO_POPULATE constant lives on Hydrator. Type hint against Hydrator and read the constant from there.

before:

use Patchlevel\Hydrator\HydratorWithContext;

function hydrate(HydratorWithContext $hydrator, Profile $profile): Profile
{
    return $hydrator->hydrate(
        Profile::class,
        ['name' => 'patchlevel'],
        [HydratorWithContext::OBJECT_TO_POPULATE => $profile],
    );
}

after:

use Patchlevel\Hydrator\Hydrator;

function hydrate(Hydrator $hydrator, Profile $profile): Profile
{
    return $hydrator->hydrate(
        Profile::class,
        ['name' => 'patchlevel'],
        [Hydrator::OBJECT_TO_POPULATE => $profile],
    );
}

Normalizer

Normalizer interface

The normalize and denormalize methods now receive a mandatory array $context parameter. The separate NormalizerWithContext interface has been removed, every normalizer gets the context. Add the parameter to all of your custom normalizers.

before:

use Patchlevel\Hydrator\Normalizer\InvalidArgument;
use Patchlevel\Hydrator\Normalizer\Normalizer;

final class NameNormalizer implements Normalizer
{
    public function normalize(mixed $value): string|null
    {
        if (!$value instanceof Name) {
            throw InvalidArgument::withWrongType(Name::class, $value);
        }

        return $value->toString();
    }

    public function denormalize(mixed $value): Name|null
    {
        if (!is_string($value)) {
            throw InvalidArgument::withWrongType('string', $value);
        }

        return new Name($value);
    }
}

after:

use Patchlevel\Hydrator\Normalizer\InvalidArgument;
use Patchlevel\Hydrator\Normalizer\Normalizer;

final class NameNormalizer implements Normalizer
{
    /** @param array<string, mixed> $context */
    public function normalize(mixed $value, array $context): string|null
    {
        if (!$value instanceof Name) {
            throw InvalidArgument::withWrongType(Name::class, $value);
        }

        return $value->toString();
    }

    /** @param array<string, mixed> $context */
    public function denormalize(mixed $value, array $context): Name|null
    {
        if (!is_string($value)) {
            throw InvalidArgument::withWrongType('string', $value);
        }

        return new Name($value);
    }
}

ReflectionTypeAwareNormalizer

ReflectionTypeAwareNormalizer has been removed in favor of TypeAwareNormalizer. It no longer hands you a native ReflectionType but a Symfony\Component\TypeInfo\Type, and the method was renamed from handleReflectionType() to handleType().

before:

use Patchlevel\Hydrator\Normalizer\Normalizer;
use Patchlevel\Hydrator\Normalizer\ReflectionTypeAwareNormalizer;

final class MyNormalizer implements Normalizer, ReflectionTypeAwareNormalizer
{
    public function handleReflectionType(ReflectionType|null $reflectionType): void
    {
        // ...
    }
}

after:

use Patchlevel\Hydrator\Normalizer\Normalizer;
use Patchlevel\Hydrator\Normalizer\TypeAwareNormalizer;
use Symfony\Component\TypeInfo\Type;

final class MyNormalizer implements Normalizer, TypeAwareNormalizer
{
    public function handleType(Type|null $type): void
    {
        // ...
    }
}

Lifecycle Hooks

The PreExtract and PostHydrate method attributes and the PreHydrate and PostExtract events have been replaced by a single LifecycleExtension with four method attributes under Patchlevel\Hydrator\Extension\Lifecycle\Attribute. Register the extension on the builder to enable them.

before:

use Patchlevel\Hydrator\MetadataHydrator;

$hydrator = new MetadataHydrator();

after:

use Patchlevel\Hydrator\CoreExtension;
use Patchlevel\Hydrator\Extension\Lifecycle\LifecycleExtension;
use Patchlevel\Hydrator\StackHydratorBuilder;

$hydrator = (new StackHydratorBuilder())
    ->useExtension(new CoreExtension())
    ->useExtension(new LifecycleExtension())
    ->build();

The hook methods must now be static and receive the $context array as a parameter. The two data hooks (PreHydrate, PostExtract) receive and return the data array, the two object hooks (PostHydrate, PreExtract) receive the object instance.

before:

use Patchlevel\Hydrator\Attribute\PostHydrate;
use Patchlevel\Hydrator\Attribute\PreExtract;

final class Profile
{
    #[PostHydrate]
    private function postHydrate(): void
    {
        // do something
    }

    #[PreExtract]
    private function preExtract(): void
    {
        // do something
    }
}

after:

use Patchlevel\Hydrator\Extension\Lifecycle\Attribute\PostHydrate;
use Patchlevel\Hydrator\Extension\Lifecycle\Attribute\PreExtract;

final class Profile
{
    /** @param array<string, mixed> $context */
    #[PostHydrate]
    public static function postHydrate(object $object, array $context): void
    {
        // do something
    }

    /** @param array<string, mixed> $context */
    #[PreExtract]
    public static function preExtract(object $object, array $context): void
    {
        // do something
    }
}

Events

The PreHydrate and PostExtract events that were dispatched through symfony/event-dispatcher are gone, together with the eventDispatcher constructor argument of the hydrator. Global listeners that ran for every class are now expressed as a Middleware. A middleware wraps the whole hydrate and extract process: adjust the data before calling $stack->next() to replace a PreHydrate listener, and adjust the returned data afterwards to replace a PostExtract listener.

before:

use Patchlevel\Hydrator\Event\PostExtract;
use Patchlevel\Hydrator\Event\PreHydrate;
use Patchlevel\Hydrator\MetadataHydrator;
use Symfony\Component\EventDispatcher\EventDispatcher;

$eventDispatcher = new EventDispatcher();

$eventDispatcher->addListener(
    PreHydrate::class,
    static function (PreHydrate $event): void {
        // adjust $event->data before hydration
    },
);

$eventDispatcher->addListener(
    PostExtract::class,
    static function (PostExtract $event): void {
        // adjust $event->data after extraction
    },
);

$hydrator = new MetadataHydrator(eventDispatcher: $eventDispatcher);

after:

use Patchlevel\Hydrator\Metadata\ClassMetadata;
use Patchlevel\Hydrator\Middleware\Middleware;
use Patchlevel\Hydrator\Middleware\Stack;

final class MyMiddleware implements Middleware
{
    /**
     * @param ClassMetadata<T>     $metadata
     * @param array<string, mixed> $data
     * @param array<string, mixed> $context
     *
     * @return T
     *
     * @template T of object
     */
    public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object
    {
        // adjust $data before hydration (was the PreHydrate event)

        return $stack->next()->hydrate($metadata, $data, $context, $stack);
    }

    /**
     * @param ClassMetadata<T>     $metadata
     * @param T                    $object
     * @param array<string, mixed> $context
     *
     * @return array<string, mixed>
     *
     * @template T of object
     */
    public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array
    {
        return $stack->next()->extract($metadata, $object, $context, $stack);
    }
}

Register the middleware on the builder. The default priority places it before the TransformMiddleware, so it runs first on hydrate and last on extract.

use Patchlevel\Hydrator\CoreExtension;
use Patchlevel\Hydrator\StackHydratorBuilder;

$hydrator = (new StackHydratorBuilder())
    ->useExtension(new CoreExtension())
    ->addMiddleware(new MyMiddleware())
    ->build();

[!NOTE] The middleware has access to the ClassMetadata, so you can decide per class whether to act, which is what you previously did by inspecting $event->metadata.

Cryptography

The cryptography support moved out of the core into a dedicated extension under the Patchlevel\Hydrator\Extension\Cryptography namespace, and it is now wired through a middleware instead of an event subscriber.

Configuration

Instead of passing a PayloadCryptographer to the hydrator constructor, register the CryptographyExtension on the builder. The cryptographer is now the BaseCryptographer, created with createWithOpenssl().

before:

use Patchlevel\Hydrator\Cryptography\PersonalDataPayloadCryptographer;
use Patchlevel\Hydrator\Cryptography\Store\InMemoryCipherKeyStore;
use Patchlevel\Hydrator\MetadataHydrator;

$cipherKeyStore = new InMemoryCipherKeyStore();
$cryptographer = PersonalDataPayloadCryptographer::createWithDefaultSettings($cipherKeyStore);
$hydrator = new MetadataHydrator(cryptographer: $cryptographer);

after:

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();

PersonalData attribute

The PersonalData attribute has been renamed to SensitiveData and moved into the cryptography extension namespace. This makes clear it is not limited to the personal data of users.

before:

use Patchlevel\Hydrator\Attribute\DataSubjectId;
use Patchlevel\Hydrator\Attribute\PersonalData;

final class EmailChanged
{
    public function __construct(
        #[DataSubjectId]
        public readonly string $profileId,
        #[PersonalData]
        public readonly string|null $email,
    ) {
    }
}

after:

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,
    ) {
    }
}

DataSubjectId attribute

DataSubjectId moved from Patchlevel\Hydrator\Attribute\DataSubjectId to Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId. You can now name a subject id and reference it from a field with subjectIdName, which lets you use multiple subjects in one class. The default name is default.

before:

use Patchlevel\Hydrator\Attribute\DataSubjectId;
use Patchlevel\Hydrator\Attribute\PersonalData;

final class ProfilesMerged
{
    public function __construct(
        #[DataSubjectId]
        public readonly string $sourceProfileId,
        #[PersonalData]
        public readonly string|null $sourceEmail,
    ) {
    }
}

after:

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,
    ) {
    }
}

Moved classes

The remaining cryptography classes moved from Patchlevel\Hydrator\Cryptography into Patchlevel\Hydrator\Extension\Cryptography. Update the imports for, among others:

  • Patchlevel\Hydrator\Cryptography\Store\CipherKeyStore to Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyStore
  • Patchlevel\Hydrator\Cryptography\Store\InMemoryCipherKeyStore to Patchlevel\Hydrator\Extension\Cryptography\Store\InMemoryCipherKeyStore
  • Patchlevel\Hydrator\Cryptography\Cipher\Cipher to Patchlevel\Hydrator\Extension\Cryptography\Cipher\Cipher
  • Patchlevel\Hydrator\Cryptography\Cipher\OpensslCipher to Patchlevel\Hydrator\Extension\Cryptography\Cipher\OpensslCipher

The CipherKeyStore interface also changed: keys are now stored and looked up through currentKeyFor(), get(), store(), remove() and removeWithSubjectId(), and a CipherKey now carries its own id.

[!WARNING] 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.

Dependencies

The symfony/event-dispatcher dependency has been dropped, since the lifecycle hooks and the cryptography no longer use it. If you relied on it transitively through this package, require it explicitly in your own composer.json.