Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0155f30
Lock file maintenance
renovate[bot] Feb 18, 2026
efc383f
Lock file maintenance
renovate[bot] Feb 19, 2026
e66027f
Lock file maintenance
renovate[bot] Feb 21, 2026
0b8df60
Lock file maintenance
renovate[bot] Feb 24, 2026
306a515
Lock file maintenance
renovate[bot] Feb 25, 2026
d997a05
Lock file maintenance
renovate[bot] Feb 27, 2026
5c68f0d
backport context feature
DavidBadura Feb 27, 2026
58b7180
Merge pull request #162 from patchlevel/backport-context-feature
DavidBadura Feb 27, 2026
e152cfb
backport stack hydrator
DavidBadura Feb 27, 2026
377ea9e
Merge pull request #163 from patchlevel/backport-stack-hydrator
DavidBadura Feb 27, 2026
c53de6e
backport lifecycle and cryptography extension
DavidBadura Feb 27, 2026
5fd42f6
Lock file maintenance
renovate[bot] Feb 28, 2026
d1865b7
Merge pull request #164 from patchlevel/backport-lifecycle-and-crypto…
DavidBadura Feb 28, 2026
19d6107
mark stack hydrator experimental
DavidBadura Feb 28, 2026
22f3b9c
Merge pull request #165 from patchlevel/mark-stack-hydrator-experimental
DavidBadura Feb 28, 2026
9286f6f
Lock file maintenance
renovate[bot] Mar 5, 2026
e5c60aa
improve cryptography implementation
DavidBadura Mar 5, 2026
ad62a62
Lock file maintenance
renovate[bot] Mar 6, 2026
9becc90
Lock file maintenance
renovate[bot] Mar 7, 2026
3e9e1e8
add stack hydrator with cryptography benchmark
DavidBadura Mar 9, 2026
24f2efc
use constants for array keys
DavidBadura Mar 9, 2026
9c10c87
Merge pull request #166 from patchlevel/improve-cryptography
DavidBadura Mar 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 73 additions & 72 deletions composer.lock

Large diffs are not rendered by default.

82 changes: 71 additions & 11 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,54 @@ parameters:
count: 1
path: src/Cryptography/PersonalDataPayloadCryptographer.php

-
message: '#^Offset ''k'' on array\{v\: 1, a\: non\-empty\-string, k\: non\-empty\-string, n\?\: non\-empty\-string, d\: non\-empty\-string, t\?\: non\-empty\-string\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Extension/Cryptography/BaseCryptographer.php

-
message: '#^Parameter \#1 \$subjectId of callable Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\CipherKeyFactory expects non\-empty\-string, string given\.$#'
identifier: argument.type
count: 1
path: src/Extension/Cryptography/BaseCryptographer.php

-
message: '#^Strict comparison using \=\=\= between non\-empty\-string and null will always evaluate to false\.$#'
identifier: identical.alwaysFalse
count: 1
path: src/Extension/Cryptography/BaseCryptographer.php

-
message: '#^Parameter \#1 \$data of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\EncryptedData constructor expects non\-empty\-string, string given\.$#'
identifier: argument.type
count: 1
path: src/Extension/Cryptography/Cipher/OpensslCipher.php

-
message: '#^Parameter \#1 \$data of function openssl_decrypt expects string, string\|false given\.$#'
identifier: argument.type
count: 1
path: src/Extension/Cryptography/Cipher/OpensslCipher.php

-
message: '#^Parameter \#3 \$nonce of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\EncryptedData constructor expects non\-empty\-string\|null, string\|null given\.$#'
identifier: argument.type
count: 1
path: src/Extension/Cryptography/Cipher/OpensslCipher.php

-
message: '#^Parameter \#1 \$id of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#'
identifier: argument.type
count: 1
path: src/Extension/Cryptography/Cipher/OpensslCipherKeyFactory.php

-
message: '#^Parameter \#3 \$key of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#'
identifier: argument.type
count: 1
path: src/Extension/Cryptography/Cipher/OpensslCipherKeyFactory.php

-
message: '#^Method Patchlevel\\Hydrator\\Guesser\\BuiltInGuesser\:\:guess\(\) has parameter \$type with generic class Symfony\\Component\\TypeInfo\\Type\\ObjectType but does not specify its types\: T$#'
identifier: missingType.generics
Expand Down Expand Up @@ -67,31 +115,31 @@ parameters:
path: src/Metadata/AttributeMetadataFactory.php

-
message: '#^Property Patchlevel\\Hydrator\\Metadata\\ClassMetadata\<T of object \= object\>\:\:\$reflection \(ReflectionClass\<T of object \= object\>\) does not accept ReflectionClass\<object\>\.$#'
message: '#^Property Patchlevel\\Hydrator\\Normalizer\\EnumNormalizer\:\:\$enum \(class\-string\<BackedEnum\>\|null\) does not accept string\.$#'
identifier: assign.propertyType
count: 1
path: src/Metadata/ClassMetadata.php
path: src/Normalizer/EnumNormalizer.php

-
message: '#^Dead catch \- Patchlevel\\Hydrator\\CircularReference is never thrown in the try block\.$#'
identifier: catch.neverThrown
message: '#^Parameter \#2 \$data of method Patchlevel\\Hydrator\\Hydrator\:\:hydrate\(\) expects array\<string, mixed\>, array given\.$#'
identifier: argument.type
count: 1
path: src/MetadataHydrator.php
path: src/Normalizer/ObjectMapNormalizer.php

-
message: '#^Property Patchlevel\\Hydrator\\Normalizer\\EnumNormalizer\:\:\$enum \(class\-string\<BackedEnum\>\|null\) does not accept string\.$#'
identifier: assign.propertyType
message: '#^Parameter \#2 \$data of method Patchlevel\\Hydrator\\HydratorWithContext\:\:hydrate\(\) expects array\<string, mixed\>, array given\.$#'
identifier: argument.type
count: 1
path: src/Normalizer/EnumNormalizer.php
path: src/Normalizer/ObjectMapNormalizer.php

-
message: '#^Parameter \#2 \$data of method Patchlevel\\Hydrator\\Hydrator\:\:hydrate\(\) expects array\<string, mixed\>, array given\.$#'
message: '#^Parameter \#2 \$data of method Patchlevel\\Hydrator\\Hydrator\:\:hydrate\(\) expects array\<string, mixed\>, array\<mixed, mixed\> given\.$#'
identifier: argument.type
count: 1
path: src/Normalizer/ObjectMapNormalizer.php
path: src/Normalizer/ObjectNormalizer.php

-
message: '#^Parameter \#2 \$data of method Patchlevel\\Hydrator\\Hydrator\:\:hydrate\(\) expects array\<string, mixed\>, array\<mixed, mixed\> given\.$#'
message: '#^Parameter \#2 \$data of method Patchlevel\\Hydrator\\HydratorWithContext\:\:hydrate\(\) expects array\<string, mixed\>, array\<mixed, mixed\> given\.$#'
identifier: argument.type
count: 1
path: src/Normalizer/ObjectNormalizer.php
Expand All @@ -108,6 +156,12 @@ parameters:
count: 1
path: src/Normalizer/ReflectionTypeUtil.php

-
message: '#^Property Patchlevel\\Hydrator\\Tests\\Unit\\Extension\\Cryptography\\Fixture\\ChildWithSensitiveDataWithIdentifierDto\:\:\$email is never read, only written\.$#'
identifier: property.onlyWritten
count: 1
path: tests/Unit/Extension/Cryptography/Fixture/ChildWithSensitiveDataWithIdentifierDto.php

-
message: '#^Method Patchlevel\\Hydrator\\Tests\\Unit\\Fixture\\DtoWithHooks\:\:postHydrate\(\) is unused\.$#'
identifier: method.unused
Expand Down Expand Up @@ -179,3 +233,9 @@ parameters:
identifier: cast.string
count: 2
path: tests/Unit/Normalizer/ArrayShapeNormalizerTest.php

-
message: '#^Parameter \#1 \$class of method Patchlevel\\Hydrator\\StackHydrator\:\:hydrate\(\) expects class\-string\<Unknown\>, string given\.$#'
identifier: argument.type
count: 1
path: tests/Unit/StackHydratorTest.php
18 changes: 18 additions & 0 deletions src/CoreExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator;

use Patchlevel\Hydrator\Guesser\BuiltInGuesser;
use Patchlevel\Hydrator\Middleware\TransformMiddleware;

/** @experimental */
final class CoreExtension implements Extension
{
public function configure(StackHydratorBuilder $builder): void
{
$builder->addMiddleware(new TransformMiddleware(), -64);
$builder->addGuesser(new BuiltInGuesser(), -64);
}
}
11 changes: 11 additions & 0 deletions src/Extension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator;

/** @experimental */
interface Extension
{
public function configure(StackHydratorBuilder $builder): void;
}
17 changes: 17 additions & 0 deletions src/Extension/Cryptography/Attribute/DataSubjectId.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Extension\Cryptography\Attribute;

use Attribute;

/** @experimental */
#[Attribute(Attribute::TARGET_PROPERTY)]
final class DataSubjectId
{
public function __construct(
public readonly string $name = 'default',
) {
}
}
28 changes: 28 additions & 0 deletions src/Extension/Cryptography/Attribute/SensitiveData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Extension\Cryptography\Attribute;

use Attribute;
use InvalidArgumentException;

/** @experimental */
#[Attribute(Attribute::TARGET_PROPERTY)]
final class SensitiveData
{
/** @var (callable(string):mixed)|null */
public readonly mixed $fallbackCallable;

public function __construct(
public readonly mixed $fallback = null,
callable|null $fallbackCallable = null,
public readonly string $subjectIdName = 'default',
) {
$this->fallbackCallable = $fallbackCallable;

if ($this->fallbackCallable !== null && $this->fallback !== null) {
throw new InvalidArgumentException('You can only set one of fallback or fallbackCallable');
}
}
}
125 changes: 125 additions & 0 deletions src/Extension/Cryptography/BaseCryptographer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Extension\Cryptography;

use Patchlevel\Hydrator\Extension\Cryptography\Cipher\Cipher;
use Patchlevel\Hydrator\Extension\Cryptography\Cipher\CipherKeyFactory;
use Patchlevel\Hydrator\Extension\Cryptography\Cipher\DecryptionFailed;
use Patchlevel\Hydrator\Extension\Cryptography\Cipher\EncryptedData;
use Patchlevel\Hydrator\Extension\Cryptography\Cipher\EncryptionFailed;
use Patchlevel\Hydrator\Extension\Cryptography\Cipher\OpensslCipher;
use Patchlevel\Hydrator\Extension\Cryptography\Cipher\OpensslCipherKeyFactory;
use Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyNotExists;
use Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyStore;

use function is_array;

/**
* @experimental
* @phpstan-type EncryptedDataArray array{
* v: 1,
* a: non-empty-string,
* k: non-empty-string,
* n?: non-empty-string, // base64
* d: non-empty-string, // base64 ciphertext
* t?: non-empty-string, // base64 (for AEAD)
* }
*/
final class BaseCryptographer implements Cryptographer
{
private const VERSION_KEY = 'v';
private const METHOD_KEY = 'a';
private const KEY_ID_KEY = 'k';
private const NONCE_KEY = 'n';
private const DATA_KEY = 'd';
private const TAG_KEY = 't';

public function __construct(
private readonly Cipher $cipher,
private readonly CipherKeyStore $cipherKeyStore,
private readonly CipherKeyFactory $cipherKeyFactory,
) {
}

/**
* @return EncryptedDataArray
*
* @throws EncryptionFailed
*/
public function encrypt(string $subjectId, mixed $value): array
{
try {
$cipherKey = $this->cipherKeyStore->currentKeyFor($subjectId);
} catch (CipherKeyNotExists) {
$cipherKey = ($this->cipherKeyFactory)($subjectId);
$this->cipherKeyStore->store($cipherKey->id, $cipherKey);
}

$parameter = $this->cipher->encrypt($cipherKey, $value);

$result = [
self::VERSION_KEY => 1,
self::METHOD_KEY => $parameter->method,
self::KEY_ID_KEY => $cipherKey->id,
self::DATA_KEY => $parameter->data,
];

if ($parameter->nonce !== null) {
$result[self::NONCE_KEY] = $parameter->nonce;
}

if ($parameter->tag !== null) {
$result[self::TAG_KEY] = $parameter->tag;
}

return $result;
}

/**
* @param EncryptedDataArray $encryptedData
*
* @throws CipherKeyNotExists
* @throws DecryptionFailed
*/
public function decrypt(string $subjectId, mixed $encryptedData): mixed
{
$keyId = $encryptedData[self::KEY_ID_KEY] ?? null;

if ($keyId === null) {
throw DecryptionFailed::missingKeyId();
}

$cipherKey = $this->cipherKeyStore->get($keyId);

return $this->cipher->decrypt(
$cipherKey,
new EncryptedData(
$encryptedData[self::DATA_KEY],
$encryptedData[self::METHOD_KEY],
$encryptedData[self::NONCE_KEY] ?? null,
$encryptedData[self::TAG_KEY] ?? null,
),
);
}

public function supports(mixed $value): bool
{
return is_array($value)
&& isset($value[self::VERSION_KEY], $value[self::METHOD_KEY], $value[self::KEY_ID_KEY], $value[self::DATA_KEY])
&& $value[self::VERSION_KEY] === 1;
}

/** @param non-empty-string $method */
public static function createWithOpenssl(
CipherKeyStore $cryptoStore,
string $method = OpensslCipherKeyFactory::DEFAULT_METHOD,
): static {
return new self(
new OpensslCipher(),
$cryptoStore,
new OpensslCipherKeyFactory($method),
);
}
}
15 changes: 15 additions & 0 deletions src/Extension/Cryptography/Cipher/Cipher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Extension\Cryptography\Cipher;

/** @experimental */
interface Cipher
{
/** @throws EncryptionFailed */
public function encrypt(CipherKey $key, mixed $data): EncryptedData;

/** @throws DecryptionFailed */
public function decrypt(CipherKey $key, EncryptedData $parameter): mixed;
}
28 changes: 28 additions & 0 deletions src/Extension/Cryptography/Cipher/CipherKey.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Extension\Cryptography\Cipher;

use DateTimeImmutable;
use SensitiveParameter;

/** @experimental */
final class CipherKey
{
/**
* @param non-empty-string $id
* @param non-empty-string $subjectId
* @param non-empty-string $key
* @param non-empty-string $method
*/
public function __construct(
public readonly string $id,
public readonly string $subjectId,
#[SensitiveParameter]
public readonly string $key,
public readonly string $method,
public readonly DateTimeImmutable $createdAt,
) {
}
}
16 changes: 16 additions & 0 deletions src/Extension/Cryptography/Cipher/CipherKeyFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Extension\Cryptography\Cipher;

/** @experimental */
interface CipherKeyFactory
{
/**
* @param non-empty-string $subjectId
*
* @throws CreateCipherKeyFailed
*/
public function __invoke(string $subjectId): CipherKey;
}
Loading
Loading