Add Mapper facade and Dto::from() shortcut for heterogeneous sources#111
Merged
Conversation
Introduces PhpCollective\Dto\Mapper and PhpCollective\Dto\ObjectFactory so
callers can hydrate a DTO from any supported source through a single entry
point instead of picking between createFromArray, fromUnserialized and
new + fromArray per source shape.
Sources recognized out of the box:
- array
- Dto instance (copied via touchedToArray so only set fields propagate)
- FromArrayToArrayInterface implementor
- JsonSerializable implementor
- JSON string
- plain object (public properties via get_object_vars)
Fluent modifiers on ObjectFactory cover the less common cases:
- ignoreMissing(bool)
- withKeyType(?string) for dashed/underscored source keys
- only(array) for partial hydration
Dto::from() is a typed shortcut that delegates to the same extraction and
returns static, so static analysers infer the concrete DTO type without
template annotations — ideal for the common request -> DTO case:
$user = UserDto::from($request->getParsedBody());
Includes tests covering all supported sources, each modifier, error paths
(unsupported source, invalid JSON, non-DTO target, JsonSerializable scalar),
the Dto::from() shortcut, and the shared Mapper::toArray() helper. Adds a
"Mapping From Arbitrary Sources" section to the runtime API guide covering
the facade, the shortcut, sources table, DTO-to-DTO copy semantics, and
framework examples.
…owing The @template T + class-string<T> annotation caused PHPStan to narrow the runtime is_a() guard to always-true on the CI version, breaking the build. The runtime check is still necessary to reject unrelated class names passed as strings. Users wanting static return-type inference should use the Dto::from() shortcut on the target DTO class, which returns static and works without any template annotation at the call site. The Mapper::map() fluent facade now returns the abstract Dto type; callers can assert or assign to a typed variable if they need the concrete class.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #111 +/- ##
============================================
+ Coverage 82.80% 83.06% +0.26%
- Complexity 1530 1555 +25
============================================
Files 43 45 +2
Lines 3756 3833 +77
============================================
+ Hits 3110 3184 +74
- Misses 646 649 +3 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
# Conflicts: # docs/guide/runtime-api.md # src/Dto/Dto.php
There was a problem hiding this comment.
Pull request overview
Adds a unified runtime mapping API so callers can hydrate DTOs from heterogeneous source shapes (arrays, JSON strings, DTO instances, JsonSerializable, etc.) without choosing between multiple constructors/factories.
Changes:
- Introduces
Mapper::map()+ObjectFactoryfluent facade for mapping from arbitrary sources into DTO subclasses. - Adds
Mapper::toArray()as the shared “source → array payload” extractor. - Adds
Dto::from(mixed $source): staticshortcut delegating toMapper::toArray()withignoreMissing=true, plus docs and tests.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
src/Mapper.php |
New facade + source-shape detection/extraction into array payloads. |
src/ObjectFactory.php |
New fluent builder applying modifiers (ignoreMissing, withKeyType, only) then dispatching to createFromArray(). |
src/Dto/Dto.php |
Adds Dto::from() typed shortcut that routes through Mapper::toArray(). |
tests/MapperTest.php |
Adds tests covering supported source types, modifiers, and error paths. |
docs/guide/runtime-api.md |
Documents the new mapping entry points, modifiers, and supported sources. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Addresses review feedback on the Mapper facade: - Mapper::toArray() now rejects list arrays (and arrays with non-string keys) at the entry point instead of letting them reach Dto::hasField() where they would produce a confusing TypeError. The new assertAssociative() helper is applied to the array branch, the JSON string branch, and the JsonSerializable-returning-array branch. Empty arrays are allowed since they are ambiguous and hydrate to a default DTO. - ObjectFactory::only() docblock and the runtime API guide now make it explicit that the filter operates on source keys as they appear in the input, not on canonical DTO field names. When combined with withKeyType() callers must pass the inflected source form. Adds tests for list arrays, JSON list strings, integer-keyed arrays, JsonSerializable payloads that return lists, empty arrays, and the only() source-key semantics with TYPE_UNDERSCORED.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Introduces a unified entry point for hydrating DTOs from any supported source shape, removing the need for callers to pick between
createFromArray,fromUnserialized, andnew + fromArraydepending on whether the input is an array, a JSON string, another DTO, or a plain object.Two complementary APIs:
1.
Dto::from()— typed shortcut (common case)Returns
static, so PHPStan and IDEs infer the concrete DTO type at every call site without any template annotations. Defaults toignoreMissing = trueso controllers can feed raw request payloads without pre-filtering. This is the recommended entry point when the target type is fixed.2.
Mapper::map()— fluent facade (edge cases)ObjectFactory::to()returns the abstractDtotype (no template annotation — see Design notes below). Callers wanting a typed return from the facade form should either use theDto::from()shortcut or annotate the assignment locally.Sources recognized
Both entry points extract an array payload via
Mapper::toArray():arrayDtoinstancetouchedToArray()— only fields that were set propagateFromArrayToArrayInterfaceimplementortoArray()JsonSerializableimplementorjsonSerialize()coerced to array (list arrays rejected)stringJson::decode(..., true)(list arrays rejected, decode errors wrapped)objectget_object_vars()— public propsList-shaped payloads (e.g.
[1, 2, 3]or JSON"[1, 2]") and arrays with non-string keys are rejected up front with a clearInvalidArgumentExceptioninstead of producing a confusingTypeErrordeeper inDto::hasField(string). Empty arrays are allowed and hydrate to a default DTO.Design notes
touchedToArray(), nottoArray(). Only fields the source actually set propagate to the copy. Untouched fields remainnullon the copy and absent from itstouchedFields(). This matches the semantics users expect from "copy this DTO" and avoids accidentally widening the set of fields marked dirty downstream.ObjectFactorymodifiers default to ergonomic values.ignoreMissing = true,keyType = null,only = null— chaining is only needed when deviating.only()filters on source keys, not canonical DTO field names. When combined withwithKeyType()callers must pass the inflected source form (e.g.['first_name']withTYPE_UNDERSCORED, not['firstName']). Filtering happens before key-type normalization so the payload contract stays predictable. Documented in the method PHPDoc and the runtime API guide.ObjectFactory::to(). An initial revision used@template T of Dto+class-string<T>for typed returns, but CI's PHPStan narrowed the runtimeis_a()guard to always-true and failed the build. Dropping the template keeps the runtime safety check and pushes typed-return users towardDto::from(), which returnsstaticnatively.Mapper::map($request->getParsedBody())explicitly. Adding transparent PSR-7 support later is trivial (softinstanceofcheck againstPsr\Http\Message\ServerRequestInterface) and non-breaking.to()only accepts DTO subclasses and throws on anything else. Supporting arbitrary classes / CakePHP entities would need a separate hydration layer and is a logical follow-up.Changes
src/Mapper.php— new facade.map()returnsObjectFactory;toArray()is a shared static extractor reused byDto::from(). PrivateassertAssociative()helper rejects list/integer-keyed payloads at the entry point.src/ObjectFactory.php— new fluent builder withignoreMissing,withKeyType,only, andto. Runtimeis_a()guard ensures non-DTO targets throw cleanly.src/Dto/Dto.php— newfrom(mixed $source): staticmethod. One-liner delegating tostatic::createFromArray(Mapper::toArray($source), true).tests/MapperTest.php— 26 tests covering each supported source (array, DTO, JSON string,JsonSerializable,FromArrayToArrayInterface, plain object), each modifier (ignoreMissing,withKeyType,only), each error path (unsupported source, invalid JSON, non-DTO target, scalarJsonSerializable, list arrays in every branch, integer-keyed arrays), theDto::from()shortcut, and theonly()source-key semantics withTYPE_UNDERSCORED.docs/guide/runtime-api.md— new "Mapping From Arbitrary Sources" section with the shortcut, the facade, sources table, modifiers table (including theonly()source-key clarification), list-rejection note, DTO-to-DTO copy semantics, and Cake/Laravel examples.Test status
tests/Dto/DtoTest.php+tests/MapperTest.php: 166/166 passing, 378 assertions.JsonSchemaoptional-dependency errors.Relationship to other PRs
Merged with master after #110 (TransformerRegistry) landed. Both features are independent but combine cleanly:
Dto::from()andMapper::map()route throughcreateFromArray→setFromArray, which consults theTransformerRegistry, so global casters and serializers automatically apply to values passing through the new mapping API.