From 8400f87e96e9d1a8ea26b0c4d28c04d84661c4d3 Mon Sep 17 00:00:00 2001 From: mscherer Date: Sat, 11 Apr 2026 02:18:35 +0200 Subject: [PATCH 1/2] Add TransformerRegistry for global type-based casters and serializers Introduces PhpCollective\Dto\Transformer\TransformerRegistry so that casters (array -> object) and serializers (object -> array/scalar) can be registered once per type and apply automatically to every DTO field whose declared type matches, instead of repeating per-field factory / serialize metadata across many fields. Lookups match the exact type first, then walk parent classes and interfaces. Per-field factory and serialize always win over the registry. When any entry is registered, generated DTOs fall back from the optimized fast path to the reflective path so the registry is always consulted. Includes tests covering caster hit, serializer hit, factory precedence, inheritance matching, removal/clear, and backslash normalization, plus an updated Custom Casters guide and runtime API reference. --- docs/guide/custom-casters.md | 149 ++++++++++++++++ docs/guide/runtime-api.md | 32 +++- src/Dto/Dto.php | 21 ++- src/Transformer/TransformerRegistry.php | 219 ++++++++++++++++++++++++ tests/Dto/DtoTest.php | 103 +++++++++++ 5 files changed, 520 insertions(+), 4 deletions(-) create mode 100644 src/Transformer/TransformerRegistry.php diff --git a/docs/guide/custom-casters.md b/docs/guide/custom-casters.md index 4eb56d3..c65e381 100644 --- a/docs/guide/custom-casters.md +++ b/docs/guide/custom-casters.md @@ -384,3 +384,152 @@ Field::class('status', Status::class) ->factory('fromString') ->serialize('string') ``` + +## Global Type-Based Transformers + +The options above (`factory`, `serialize`) are declared **per field** in your DTO schema. +When the same value object type appears in dozens of fields across many DTOs, repeating +the factory on every field becomes tedious. The `TransformerRegistry` lets you register +a caster or serializer **once per type** and have it apply automatically to every field +whose declared type matches. + +### Registering Casters and Serializers + +```php +use PhpCollective\Dto\Transformer\TransformerRegistry; + +// Array -> Object (used during fromArray / createFromArray / new MyDto($data)) +TransformerRegistry::addCaster( + \DateTimeImmutable::class, + fn (string $value): \DateTimeImmutable => new \DateTimeImmutable($value), +); + +// Object -> scalar/array (used during toArray / touchedToArray / jsonSerialize) +TransformerRegistry::addSerializer( + \DateTimeInterface::class, + fn (\DateTimeInterface $value): string => $value->format(DATE_ATOM), +); +``` + +Register them once during application bootstrap. Every DTO field declared as +`\DateTimeImmutable` (or any subclass/implementor of `\DateTimeInterface` on output) +will now be transformed automatically — no per-field `factory` or `serialize` needed. + +### Precedence Rules + +The registry participates in a clear priority chain: + +**`fromArray` (array → value):** + +1. Nested DTO handling +2. Collection handling +3. `serialize: 'FromArrayToArray'` / `serialize: 'array'` per field +4. **Per-field `factory`** (wins over registry) +5. Enum handling +6. **`TransformerRegistry::findCaster()`** ← new +7. Default constructor fallback (`new $type($value)`) + +**`toArray` (value → array):** + +1. Nested DTO `->toArray()` +2. Collection `->toArray()` +3. **Per-field `serialize`** (wins over registry) +4. Unit enum handling +5. **`TransformerRegistry::findSerializer()`** ← new +6. Value passed through as-is + +This means explicit schema configuration always overrides global registry entries. + +### Inheritance Matching + +Lookups try an exact class-name match first, then walk registered types to find a parent +class or interface match: + +```php +TransformerRegistry::addSerializer( + \DateTimeInterface::class, + fn (\DateTimeInterface $value): string => $value->format(DATE_ATOM), +); + +// Both \DateTime and \DateTimeImmutable are serialized by the entry above. +``` + +Register the most specific type you care about; interface-level registrations are a +convenient catch-all. + +### Framework Integration Examples + +**CakePHP** (in `Application::bootstrap()`): + +```php +use PhpCollective\Dto\Transformer\TransformerRegistry; +use Cake\I18n\DateTime as CakeDateTime; + +TransformerRegistry::addCaster( + CakeDateTime::class, + fn (mixed $value): CakeDateTime => new CakeDateTime($value), +); +TransformerRegistry::addSerializer( + CakeDateTime::class, + fn (CakeDateTime $value): string => $value->toIso8601String(), +); +``` + +**Laravel** (in `AppServiceProvider::boot()`): + +```php +use PhpCollective\Dto\Transformer\TransformerRegistry; +use Illuminate\Support\Carbon; + +TransformerRegistry::addCaster( + Carbon::class, + fn (mixed $value): Carbon => Carbon::parse($value), +); +TransformerRegistry::addSerializer( + Carbon::class, + fn (Carbon $value): string => $value->toIso8601String(), +); +``` + +### Testing + +In tests, clear the registry between cases to avoid leakage across tests: + +```php +protected function tearDown(): void +{ + parent::tearDown(); + TransformerRegistry::clear(); +} +``` + +### API Reference + +| Method | Description | +|--------|-------------| +| `addCaster(string $type, callable $caster)` | Register a caster for a class/interface. | +| `addSerializer(string $type, callable $serializer)` | Register a serializer for a class/interface. | +| `removeCaster(string $type)` | Remove a registered caster. | +| `removeSerializer(string $type)` | Remove a registered serializer. | +| `hasCaster(string $type): bool` | Check if a caster is registered (exact match). | +| `hasSerializer(string $type): bool` | Check if a serializer is registered (exact match). | +| `findCaster(string $type): ?callable` | Look up a caster (exact then inheritance). | +| `findSerializer(object $value): ?callable` | Look up a serializer for an instance. | +| `hasAny(): bool` | Whether any entry is registered. | +| `clear()` | Remove all entries. | + +### When to Use What + +| Need | Use | +|------|-----| +| Transform a single one-off field | Per-field `factory` / `serialize` in the schema | +| Transform the same type across many fields / DTOs | `TransformerRegistry` | +| Override registry behaviour for a specific field | Per-field `factory` / `serialize` (wins over registry) | +| Roundtrip a custom value object | Implement `FromArrayToArrayInterface` | + +::: tip Performance +When any entry is registered, generated DTOs fall back from the optimized fast path +to the reflective code path to ensure the registry is consulted. If you rely on the +`HAS_FAST_PATH` optimization, prefer per-field `factory`/`serialize` over the registry +for hot-path DTOs. +::: diff --git a/docs/guide/runtime-api.md b/docs/guide/runtime-api.md index ace3c49..7e479a5 100644 --- a/docs/guide/runtime-api.md +++ b/docs/guide/runtime-api.md @@ -245,13 +245,43 @@ Dto::setCollectionFactory(fn (array $items) => collect($items)); This is useful for framework-native collection classes in Laravel, CakePHP, or Symfony integrations. +## Transformers + +### `TransformerRegistry` + +Register global, type-based casters (array → object) and serializers (object → array/scalar) +that apply to every DTO field whose declared type matches. This avoids repeating per-field +`factory` / `serialize` declarations for common value object types like `DateTimeImmutable`, +`Money`, or framework `DateTime` wrappers. + +```php +use PhpCollective\Dto\Transformer\TransformerRegistry; + +TransformerRegistry::addCaster( + \DateTimeImmutable::class, + fn (string $value): \DateTimeImmutable => new \DateTimeImmutable($value), +); + +TransformerRegistry::addSerializer( + \DateTimeInterface::class, + fn (\DateTimeInterface $value): string => $value->format(DATE_ATOM), +); +``` + +Per-field `factory` and `serialize` metadata always win over the registry. Lookups match +the exact type first, then walk parent classes and interfaces. See +[Custom Casters](./custom-casters#global-type-based-transformers) for the full precedence +rules and framework integration examples. + ### Resetting Global Runtime State -Because collection factories and default key types are static global settings, tests should reset them explicitly: +Because collection factories, default key types, and transformers are static global settings, +tests should reset them explicitly: ```php Dto::setCollectionFactory(null); Dto::setDefaultKeyType(null); +TransformerRegistry::clear(); ``` ## Runtime Exceptions Worth Knowing diff --git a/src/Dto/Dto.php b/src/Dto/Dto.php index d2d31e6..7e08afa 100644 --- a/src/Dto/Dto.php +++ b/src/Dto/Dto.php @@ -9,6 +9,7 @@ use Countable; use InvalidArgumentException; use JsonSerializable; +use PhpCollective\Dto\Transformer\TransformerRegistry; use PhpCollective\Dto\Utility\Json; use ReflectionProperty; use RuntimeException; @@ -246,7 +247,7 @@ public static function create(?array $data = null, bool $ignoreMissing = false, public function __construct(?array $data = null, bool $ignoreMissing = false, ?string $type = null) { if ($data) { - if ($type === null && static::HAS_FAST_PATH) { + if ($type === null && static::HAS_FAST_PATH && !TransformerRegistry::hasAny()) { if (!$ignoreMissing) { $this->validateFieldNames($data); } @@ -309,7 +310,11 @@ public function read(array $path, $default = null) */ protected function _toArrayInternal(?string $type = null, ?array $fields = null, bool $touched = false): array { - if (!$touched && $fields === null && static::HAS_FAST_PATH && ($type === null || $type === static::TYPE_CAMEL || $type === static::TYPE_DEFAULT)) { + if ( + !$touched && $fields === null && static::HAS_FAST_PATH + && ($type === null || $type === static::TYPE_CAMEL || $type === static::TYPE_DEFAULT) + && !TransformerRegistry::hasAny() + ) { return $this->toArrayFast(); } @@ -353,6 +358,11 @@ protected function _toArrayInternal(?string $type = null, ?array $fields = null, throw new InvalidArgumentException('Expected UnitEnum instance'); } $value = $this->transformEnum($value); + } else { + $serializer = TransformerRegistry::findSerializer($value); + if ($serializer !== null) { + $value = $serializer($value); + } } if ($transformTo !== null) { @@ -547,7 +557,12 @@ protected function setFromArray(array $data, bool $ignoreMissing, ?string $type } elseif (!empty($fieldMeta['isClass']) && !empty($fieldMeta['enum'])) { $value = $this->createEnum($field, $value); } elseif (!empty($fieldMeta['isClass']) && !is_object($value)) { - $value = $this->createWithConstructor($field, $value, $fieldMeta); + $caster = TransformerRegistry::findCaster($fieldMeta['type']); + if ($caster !== null) { + $value = $caster($value); + } else { + $value = $this->createWithConstructor($field, $value, $fieldMeta); + } } if (!$immutable) { diff --git a/src/Transformer/TransformerRegistry.php b/src/Transformer/TransformerRegistry.php new file mode 100644 index 0000000..31dc043 --- /dev/null +++ b/src/Transformer/TransformerRegistry.php @@ -0,0 +1,219 @@ + new \DateTimeImmutable($value), + * ); + * + * TransformerRegistry::addSerializer( + * \DateTimeInterface::class, + * fn(\DateTimeInterface $value) => $value->format(DATE_ATOM), + * ); + * ``` + */ +class TransformerRegistry +{ + /** + * @var array + */ + private static array $casters = []; + + /** + * @var array + */ + private static array $serializers = []; + + /** + * Register a caster for a type. The caster receives the raw value and must + * return an instance of the target type (or compatible). + * + * @param string $type Class or interface name. + * @param callable $caster Signature: `function(mixed $value): object` + * + * @return void + */ + public static function addCaster(string $type, callable $caster): void + { + self::$casters[self::normalize($type)] = $caster; + } + + /** + * Register a serializer for a type. The serializer receives the object and + * returns its array/scalar representation for `toArray()`. + * + * @param string $type Class or interface name. + * @param callable $serializer Signature: `function(object $value): mixed` + * + * @return void + */ + public static function addSerializer(string $type, callable $serializer): void + { + self::$serializers[self::normalize($type)] = $serializer; + } + + /** + * Remove a caster for a specific type. + * + * @param string $type + * + * @return void + */ + public static function removeCaster(string $type): void + { + unset(self::$casters[self::normalize($type)]); + } + + /** + * Remove a serializer for a specific type. + * + * @param string $type + * + * @return void + */ + public static function removeSerializer(string $type): void + { + unset(self::$serializers[self::normalize($type)]); + } + + /** + * Look up a caster for a declared field type. Exact match wins; otherwise + * the first registered parent class or interface that `$type` is an + * instance of is returned. + * + * @param string $type + * + * @return callable|null + */ + public static function findCaster(string $type): ?callable + { + if (self::$casters === []) { + return null; + } + + $type = self::normalize($type); + if (isset(self::$casters[$type])) { + return self::$casters[$type]; + } + + if (!class_exists($type) && !interface_exists($type)) { + return null; + } + + foreach (self::$casters as $registered => $caster) { + if ($registered === $type) { + continue; + } + if ((class_exists($registered) || interface_exists($registered)) && is_a($type, $registered, true)) { + return $caster; + } + } + + return null; + } + + /** + * Look up a serializer for an object instance. Exact match on the concrete + * class wins; otherwise the first registered parent class or interface the + * object is an instance of is returned. + * + * @param object $value + * + * @return callable|null + */ + public static function findSerializer(object $value): ?callable + { + if (self::$serializers === []) { + return null; + } + + $class = $value::class; + if (isset(self::$serializers[$class])) { + return self::$serializers[$class]; + } + + foreach (self::$serializers as $registered => $serializer) { + if ($registered === $class) { + continue; + } + if ($value instanceof $registered) { + return $serializer; + } + } + + return null; + } + + /** + * Check whether any caster or serializer is currently registered. + * Used to bypass generated fast paths that would skip transformation. + * + * @return bool + */ + public static function hasAny(): bool + { + return self::$casters !== [] || self::$serializers !== []; + } + + /** + * @param string $type + * + * @return bool + */ + public static function hasCaster(string $type): bool + { + return isset(self::$casters[self::normalize($type)]); + } + + /** + * @param string $type + * + * @return bool + */ + public static function hasSerializer(string $type): bool + { + return isset(self::$serializers[self::normalize($type)]); + } + + /** + * Clear all registered casters and serializers. Primarily useful in tests. + * + * @return void + */ + public static function clear(): void + { + self::$casters = []; + self::$serializers = []; + } + + /** + * Normalize a type name by stripping any leading backslash. + * + * @param string $type + * + * @return string + */ + private static function normalize(string $type): string + { + return ltrim($type, '\\'); + } +} diff --git a/tests/Dto/DtoTest.php b/tests/Dto/DtoTest.php index 2eeb12e..13c98bc 100644 --- a/tests/Dto/DtoTest.php +++ b/tests/Dto/DtoTest.php @@ -31,8 +31,10 @@ use PhpCollective\Dto\Test\TestDto\TraversableDto; use PhpCollective\Dto\Test\TestDto\ValidatedDto; use PhpCollective\Dto\Test\TestDto\WrongFactoryReturnDto; +use PhpCollective\Dto\Transformer\TransformerRegistry; use PHPUnit\Framework\TestCase; use RuntimeException; +use Stringable; use TypeError; class DtoTest extends TestCase @@ -42,6 +44,7 @@ protected function tearDown(): void parent::tearDown(); Dto::setDefaultKeyType(null); Dto::setCollectionFactory(null); + TransformerRegistry::clear(); } public function testCreate(): void @@ -1751,4 +1754,104 @@ public function testMultipleRequiredFieldsErrorMessageFormat(): void ); } } + + public function testGlobalCasterAppliesToClassField(): void + { + TransformerRegistry::addCaster( + PlainClass::class, + fn (mixed $value): PlainClass => new PlainClass('cast:' . (string)$value), + ); + + $dto = new AdvancedDto(['plainData' => 'hello']); + + $plain = $dto->getPlainData(); + $this->assertInstanceOf(PlainClass::class, $plain); + $this->assertSame('cast:hello', $plain->value); + } + + public function testGlobalSerializerAppliesToObjectValue(): void + { + TransformerRegistry::addSerializer( + PlainClass::class, + fn (PlainClass $value): string => 'serialized:' . $value->value, + ); + + $dto = new AdvancedDto(); + $dto->setPlainData(new PlainClass('abc')); + + $this->assertSame( + ['plainData' => 'serialized:abc'], + $dto->toArray(null, ['plainData']), + ); + } + + public function testPerFieldFactoryWinsOverGlobalCaster(): void + { + $registryCalled = false; + TransformerRegistry::addCaster( + FactoryClass::class, + function (mixed $value) use (&$registryCalled): FactoryClass { + $registryCalled = true; + + return new FactoryClass('registry:' . (string)$value); + }, + ); + + $dto = new AdvancedDto(['factoryData' => 'direct']); + + $this->assertFalse($registryCalled, 'Global caster must not run when per-field factory is set'); + $this->assertSame('direct', $dto->getFactoryData()?->value); + } + + public function testGlobalCasterMatchesParentClassByInheritance(): void + { + TransformerRegistry::addCaster( + Stringable::class, + fn (mixed $value): PlainClass => new PlainClass('parent:' . (string)$value), + ); + + // PlainClass is not Stringable, so no match expected here. + $dto = new AdvancedDto(['plainData' => 'x']); + $this->assertSame('x', $dto->getPlainData()?->value); + + TransformerRegistry::clear(); + + // Register caster for the exact concrete type: should apply. + TransformerRegistry::addCaster( + PlainClass::class, + fn (mixed $value): PlainClass => new PlainClass('exact:' . (string)$value), + ); + + $dto = new AdvancedDto(['plainData' => 'x']); + $this->assertSame('exact:x', $dto->getPlainData()?->value); + } + + public function testRemoveAndClearCaster(): void + { + TransformerRegistry::addCaster( + PlainClass::class, + fn (mixed $value): PlainClass => new PlainClass('cast:' . (string)$value), + ); + $this->assertTrue(TransformerRegistry::hasCaster(PlainClass::class)); + + TransformerRegistry::removeCaster(PlainClass::class); + $this->assertFalse(TransformerRegistry::hasCaster(PlainClass::class)); + + // After removal, default constructor fallback kicks in again. + $dto = new AdvancedDto(['plainData' => 'plain']); + $this->assertSame('plain', $dto->getPlainData()?->value); + } + + public function testNormalizesLeadingBackslash(): void + { + TransformerRegistry::addCaster( + '\\' . PlainClass::class, + fn (mixed $value): PlainClass => new PlainClass('normalized:' . (string)$value), + ); + + $this->assertTrue(TransformerRegistry::hasCaster(PlainClass::class)); + + $dto = new AdvancedDto(['plainData' => 'val']); + $this->assertSame('normalized:val', $dto->getPlainData()?->value); + } } From 20aace62061642570efd439eebe2d090348b7175 Mon Sep 17 00:00:00 2001 From: mscherer Date: Sat, 11 Apr 2026 02:20:29 +0200 Subject: [PATCH 2/2] Add tests covering serializer inheritance, removal, and unknown types --- tests/Dto/DtoTest.php | 53 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/Dto/DtoTest.php b/tests/Dto/DtoTest.php index 13c98bc..fd6e5fa 100644 --- a/tests/Dto/DtoTest.php +++ b/tests/Dto/DtoTest.php @@ -1854,4 +1854,57 @@ public function testNormalizesLeadingBackslash(): void $dto = new AdvancedDto(['plainData' => 'val']); $this->assertSame('normalized:val', $dto->getPlainData()?->value); } + + public function testSerializerMatchesByInheritance(): void + { + TransformerRegistry::addSerializer( + Stringable::class, + fn (Stringable $value): string => 'iface:' . (string)$value, + ); + + $dto = new AdvancedDto(); + $factoryData = new FactoryClass('x'); + $dto->setFactoryData($factoryData); + // FactoryClass is not Stringable, so the interface-based serializer must not fire. + $output = $dto->toArray(null, ['factoryData']); + $this->assertSame($factoryData, $output['factoryData']); + + // StringableClass fixture implements Stringable. + $stringable = new StringableClass('abc'); + TransformerRegistry::addSerializer(StringableClass::class, fn ($v): string => 'exact:' . (string)$v); + $this->assertSame( + 'exact:abc', + (TransformerRegistry::findSerializer($stringable))($stringable), + ); + } + + public function testRemoveSerializerAndHasSerializer(): void + { + $this->assertFalse(TransformerRegistry::hasSerializer(PlainClass::class)); + $this->assertFalse(TransformerRegistry::hasAny()); + + TransformerRegistry::addSerializer( + PlainClass::class, + fn (PlainClass $value): string => 'keep:' . $value->value, + ); + $this->assertTrue(TransformerRegistry::hasSerializer(PlainClass::class)); + $this->assertTrue(TransformerRegistry::hasAny()); + + TransformerRegistry::removeSerializer(PlainClass::class); + $this->assertFalse(TransformerRegistry::hasSerializer(PlainClass::class)); + $this->assertFalse(TransformerRegistry::hasAny()); + $this->assertNull(TransformerRegistry::findSerializer(new PlainClass('x'))); + } + + public function testFindCasterReturnsNullForUnknownType(): void + { + $this->assertNull(TransformerRegistry::findCaster(PlainClass::class)); + + TransformerRegistry::addCaster( + PlainClass::class, + fn (mixed $value): PlainClass => new PlainClass((string)$value), + ); + + $this->assertNull(TransformerRegistry::findCaster('Non\\Existent\\Class\\Name')); + } }