Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
149 changes: 149 additions & 0 deletions docs/guide/custom-casters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
:::
32 changes: 31 additions & 1 deletion docs/guide/runtime-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 18 additions & 3 deletions src/Dto/Dto.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Countable;
use InvalidArgumentException;
use JsonSerializable;
use PhpCollective\Dto\Transformer\TransformerRegistry;
use PhpCollective\Dto\Utility\Json;
use ReflectionProperty;
use RuntimeException;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading