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
112 changes: 112 additions & 0 deletions docs/guide/runtime-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,118 @@ Dto::setCollectionFactory(fn (array $items) => collect($items));

This is useful for framework-native collection classes in Laravel, CakePHP, or Symfony integrations.

## Mapping From Arbitrary Sources

Generated DTOs expose typed factories (`createFromArray`, `fromUnserialized`) and
a constructor that takes an array. When your source could be any of several
shapes — a request payload, another DTO, a plain object, a JSON string — the
`Mapper` facade and the `Dto::from()` shortcut provide a single entry point so
the caller doesn't have to pick the right method per source.

### `Dto::from()` — typed shortcut

For the common DTO-in, DTO-out case, call `from()` directly on the target DTO.
It returns `static`, so the concrete DTO type is inferred without template
annotations and your IDE auto-completes methods on the result:

```php
use App\Dto\UserDto;

$user = UserDto::from($request->getParsedBody());
$copy = UserDto::from($existingUserDto);
$fromJson = UserDto::from('{"name":"Alice","email":"a@example.com"}');
```

`Dto::from()` defaults to `ignoreMissing = true` so request payloads with
extra keys pass through without pre-filtering. If you need strict mode or
other modifiers, use the `Mapper::map()` facade instead.

### `Mapper::map()` — fluent facade

```php
use PhpCollective\Dto\Dto\Dto;
use PhpCollective\Dto\Mapper;

$dto = Mapper::map($source)
->ignoreMissing(false)
->withKeyType(Dto::TYPE_UNDERSCORED)
->only(['name', 'email'])
->to(UserDto::class);
```

Modifiers:

| Method | Default | Purpose |
|--------|---------|---------|
| `ignoreMissing(bool $ignore = true)` | `true` | Silently drop unknown source keys. |
| `withKeyType(?string $type)` | `null` | Declare the inflection of the source keys (`TYPE_UNDERSCORED`, `TYPE_DASHED`, ...). |
| `only(array $fields)` | `null` | Hydrate only the listed fields. Matched against **source keys as they appear in the input** (not against canonical DTO field names), so with `withKeyType(TYPE_UNDERSCORED)` you pass `['first_name']`, not `['firstName']`. |

### Supported sources

Both `Dto::from()` and `Mapper::map()` accept:

| Source | Extraction |
|--------|------------|
| `array` | pass-through |
| `Dto` instance | `touchedToArray()` — only fields that were set |
| `FromArrayToArrayInterface` implementor | `toArray()` |
| `JsonSerializable` implementor | `jsonSerialize()` (must return an array or object) |
| JSON `string` | `json_decode(..., true)` |
| plain `object` | `get_object_vars()` — public properties only |

Unsupported sources (numbers, booleans, unparseable strings, resources) throw
`InvalidArgumentException` with a descriptive message. The same applies to
**list arrays** (e.g. `[1, 2, 3]` or JSON `"[1, 2]"`): DTO hydration operates
on associative `array<string, mixed>` payloads, so list-shaped inputs are
rejected up front rather than producing a confusing `TypeError` deeper in the
hydration stack.

### DTO-to-DTO copies

Because DTO sources extract via `touchedToArray()`, copying only propagates
fields that were actually set on the source. Fields left untouched remain
`null` on the copy and the copy's own `touchedFields()` reflects only what was
carried over:

```php
$source = new UserDto();
$source->setName('Alice'); // only 'name' is touched

$copy = UserDto::from($source);
$copy->getName(); // 'Alice'
$copy->getEmail(); // null — never set on source
```

### Framework examples

**CakePHP controller:**

```php
public function add(): ?Response
{
$user = UserDto::from($this->request->getData());
// ... use $user as a typed payload
}
```

**Laravel form request:**

```php
public function store(CreateUserRequest $request)
{
$user = UserDto::from($request->validated());
}
```

**Query string with dashed keys:**

```php
$filters = Mapper::map($request->getQueryParams())
->withKeyType(Dto::TYPE_DASHED)
->to(FilterDto::class);
```

## Transformers

### `TransformerRegistry`
Expand Down
27 changes: 27 additions & 0 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\Mapper;
use PhpCollective\Dto\Transformer\TransformerRegistry;
use PhpCollective\Dto\Utility\Json;
use ReflectionProperty;
Expand Down Expand Up @@ -132,6 +133,32 @@ public static function fromUnserialized(string $data, bool $ignoreMissing = fals
return new static($jsonUtil->decode($data, true), $ignoreMissing);
}

/**
* Typed shortcut for `Mapper::map($source)->to(static::class)`.
*
* Accepts any source supported by `Mapper::toArray()` — array, DTO,
* `FromArrayToArrayInterface`, `JsonSerializable`, JSON string, or plain
* object — and returns an instance of the concrete DTO subclass `from`
* is called on. The return type is `static`, so static analysers infer
* the exact DTO class without template annotations:
*
* ```php
* $user = UserDto::from($request->getParsedBody());
* $copy = UserDto::from($otherUserDto);
* ```
*
* For fluent modifiers (`ignoreMissing(false)`, `withKeyType(...)`,
* `only([...])`) use the `Mapper::map()` facade instead.
*
* @param mixed $source
*
* @return static
*/
public static function from(mixed $source): static
{
return static::createFromArray(Mapper::toArray($source), true);
}

/**
* Convenience wrapper for easier chaining.
*
Expand Down
172 changes: 172 additions & 0 deletions src/Mapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
<?php

declare(strict_types=1);

namespace PhpCollective\Dto;

use InvalidArgumentException;
use JsonSerializable;
use PhpCollective\Dto\Dto\Dto;
use PhpCollective\Dto\Dto\FromArrayToArrayInterface;
use PhpCollective\Dto\Utility\Json;
use Throwable;

/**
* Unified entry point for hydrating DTOs from heterogeneous sources.
*
* Instead of picking the right constructor for each input shape
* (`createFromArray`, `fromUnserialized`, `new + fromArray`, ...), callers use
* a single `Mapper::map($source)->to(Target::class)` call and the facade
* figures out how to extract an array payload from the source.
*
* Supported sources out of the box:
* - `array`
* - `PhpCollective\Dto\Dto\Dto` instance (uses `touchedToArray()`)
* - `PhpCollective\Dto\Dto\FromArrayToArrayInterface` implementor
* - `JsonSerializable` implementor
* - JSON `string`
* - any plain `object` (via `get_object_vars()`)
*
* Example:
* ```php
* use PhpCollective\Dto\Mapper;
*
* $dto = Mapper::map($request->getParsedBody())->to(UserDto::class);
* $copy = Mapper::map($existingDto)->to(UserDto::class);
* $strict = Mapper::map($jsonString)
* ->ignoreMissing(false)
* ->withKeyType(Dto::TYPE_UNDERSCORED)
* ->to(UserDto::class);
* ```
*
* For the common DTO-in, DTO-out case prefer the typed shortcut on the target
* DTO: `UserDto::from($source)`. It returns `static`, so PHPStan infers the
* concrete DTO type without template annotations.
*/
final class Mapper
{
/**
* Begin a fluent mapping from `$source`.
*
* @param mixed $source
*
* @return \PhpCollective\Dto\ObjectFactory
*/
public static function map(mixed $source): ObjectFactory
{
return new ObjectFactory($source);
}

/**
* Extract an array payload from an arbitrary supported source.
*
* Exposed as a static helper so `Dto::from()` and `ObjectFactory::to()`
* share the same detection logic.
*
* @param mixed $source
*
* @throws \InvalidArgumentException If the source shape is not supported.
*
* @return array<string, mixed>
*/
public static function toArray(mixed $source): array
{
if (is_array($source)) {
self::assertAssociative($source, 'array');

return $source;
}
Comment thread
dereuromark marked this conversation as resolved.

if ($source instanceof Dto) {
return $source->touchedToArray();
}

if ($source instanceof FromArrayToArrayInterface) {
return $source->toArray();
}

if ($source instanceof JsonSerializable) {
$data = $source->jsonSerialize();
if (is_array($data)) {
self::assertAssociative($data, 'JsonSerializable payload');

return $data;
}
if (is_object($data)) {
return get_object_vars($data);
}

throw new InvalidArgumentException(sprintf(
'JsonSerializable source returned unsupported payload of type "%s".',
get_debug_type($data),
));
}

if (is_string($source)) {
try {
$decoded = (new Json())->decode($source, true);
} catch (Throwable $e) {
throw new InvalidArgumentException(
'String source could not be decoded as JSON: ' . $e->getMessage(),
0,
$e,
);
}
if (!is_array($decoded)) {
throw new InvalidArgumentException(
'String source could not be decoded as a JSON object into an array.',
);
}
self::assertAssociative($decoded, 'JSON string');

Comment thread
dereuromark marked this conversation as resolved.
return $decoded;
}

if (is_object($source)) {
return get_object_vars($source);
}

throw new InvalidArgumentException(sprintf(
'Cannot map source of type "%s" — expected array, object, or JSON string.',
get_debug_type($source),
));
}

/**
* Reject list arrays (e.g. `[1, 2, 3]` or JSON `"[1, 2]"`). DTO hydration
* operates on associative `array<string, mixed>` payloads and would later
* produce a confusing `TypeError` from `Dto::hasField(string)` if given an
* integer-keyed sequence. Failing here yields a clearer error at the
* actual misuse site.
*
* @param array<array-key, mixed> $data
* @param string $sourceDescription Human-readable source label for the message.
*
* @throws \InvalidArgumentException If `$data` is a list or has any non-string keys.
*
* @return void
*/
private static function assertAssociative(array $data, string $sourceDescription): void
{
if ($data === []) {
return;
}

if (array_is_list($data)) {
throw new InvalidArgumentException(sprintf(
'Cannot map %s: expected an associative array keyed by field names, got a list.',
$sourceDescription,
));
}

foreach ($data as $key => $_) {
if (!is_string($key)) {
throw new InvalidArgumentException(sprintf(
'Cannot map %s: all keys must be strings, got "%s".',
$sourceDescription,
get_debug_type($key),
));
}
}
}
}
Loading
Loading