@@ -50,31 +50,386 @@ $primitiveAdapter = $serializer->adapter(
5050 PrimitiveTypeAdapter::class,
5151 NamedType::wrap(Item::class, [Carbon::class])
5252);
53- // -> ['int' => 123, ...]
54- $primitiveAdapter->serialize(new Item(...));
53+ $primitiveAdapter->serialize(new Item(...)) // -> ['int' => 123, ...]
5554
5655$jsonAdapter = $serializer->adapter(
5756 JsonTypeAdapter::class,
5857 NamedType::wrap(Item::class, [PrimitiveType::int()])
5958);
60- // new Item(123, ...)
61- $jsonAdapter->deserialize('{"int": 123, ...}');
59+ $jsonAdapter->deserialize('{"int": 123, ...}') // -> new Item(123, ...)
6260```
6361
64- ## Documentation
62+ ### Custom mappers
6563
66- Basic documentation is available in [ docs/] ( docs ) . For examples, you can look at the
67- test suite: [ tests/Integration] ( tests/Integration ) .
64+ Mappers are the simplest form customizing serialization of types. All you have
65+ to do is to mark a method with either ` #[MapTo()] ` or ` #[MapFrom] ` attribute,
66+ specify the type in question as first parameter or return type and the serializer
67+ will handle the rest automatically. A single mapper may have as many map methods as you wish.
68+
69+ ``` php
70+ final class DateTimeMapper
71+ {
72+ #[MapTo(PrimitiveTypeAdapter::class)]
73+ public function serialize(DateTime $value): string
74+ {
75+ return $value->format(DateTimeInterface::RFC3339_EXTENDED);
76+ }
77+
78+ #[MapFrom(PrimitiveTypeAdapter::class)]
79+ public function deserialize(string $value): DateTime
80+ {
81+ return new DateTime($value);
82+ }
83+ }
84+
85+ $serializer = (new SerializerBuilder())
86+ ->addMapperLast(new DateTimeMapper())
87+ ->build();
88+ ```
89+
90+ With mappers, you can even handle complex types - such as generics or inheritance:
91+
92+ ``` php
93+ final class ArrayMapper
94+ {
95+ #[MapTo(PrimitiveTypeAdapter::class)]
96+ public function to(array $value, Type $type, Serializer $serializer): array
97+ {
98+ $itemAdapter = $serializer->adapter(PrimitiveTypeAdapter::class, $type->arguments[1]);
99+
100+ return array_map(fn ($item) => $itemAdapter->serialize($item), $value);
101+ }
102+
103+ #[MapFrom(PrimitiveTypeAdapter::class)]
104+ public function from(array $value, Type $type, Serializer $serializer): array
105+ {
106+ $itemAdapter = $serializer->adapter(PrimitiveTypeAdapter::class, $type->arguments[1]);
107+
108+ return array_map(fn ($item) => $itemAdapter->deserialize($item), $value);
109+ }
110+ }
111+
112+ final class BackedEnumMapper
113+ {
114+ #[MapTo(PrimitiveTypeAdapter::class, new BaseTypeAcceptedByAcceptanceStrategy(BackedEnum::class))]
115+ public function to(BackedEnum $value): string|int
116+ {
117+ return $value->value;
118+ }
119+
120+ #[MapFrom(PrimitiveTypeAdapter::class, new BaseTypeAcceptedByAcceptanceStrategy(BackedEnum::class))]
121+ public function from(string|int $value, Type $type): BackedEnum
122+ {
123+ $enumClass = $type->name;
124+
125+ return $enumClass::tryFrom($value);
126+ }
127+ }
128+ ```
129+
130+ ## Type adapter factories
131+
132+ Besides type mappers which satisfy most of the needs, you can use type adapter factories
133+ to precisely control how each type is serialized.
134+
135+ The idea is the following: when building a serializer, you add all of the factories you want
136+ to use in order of priority:
137+
138+ ``` php
139+ (new SerializerBuilder())
140+ ->addMapperLast(new TestMapper()) // #2 - then this one
141+ ->addFactoryLast(new TestFactory()) // #3 - and this one last
142+ ->addFactory(new TestFactory()) // #1 - attempted first
143+ ```
144+
145+ A factory has the following signature:
146+
147+ ``` php
148+ public function create(string $typeAdapterType, Type $type, Attributes $attributes, Serializer $serializer): ?TypeAdapter
149+ ```
150+
151+ If you return ` null ` , the next factory is called. Otherwise, the returned type adapter is used.
152+
153+ The serialized is entirely built using type adapter factories. Every type that is
154+ supported out-of-the-box also has it's factory and can be overwritten just by doing
155+ ` ->addFactoryLast() ` . Type mappers are also just fancy adapter factories under the hood.
156+
157+ This is how you can use them:
158+
159+ ``` php
160+ class NullableTypeAdapterFactory implements TypeAdapterFactory
161+ {
162+ public function create(string $typeAdapterType, Type $type, Attributes $attributes, Serializer $serializer): ?TypeAdapter
163+ {
164+ if ($typeAdapterType !== PrimitiveTypeAdapter::class || !$type instanceof NullableType) {
165+ return null;
166+ }
167+
168+ return new NullableTypeAdapter(
169+ $serializer->adapter($typeAdapterType, $type->innerType, $attributes),
170+ );
171+ }
172+ }
173+
174+ class NullableTypeAdapter implements PrimitiveTypeAdapter
175+ {
176+ public function __construct(
177+ private readonly PrimitiveTypeAdapter $delegate,
178+ ) {
179+ }
180+
181+ public function serialize(mixed $value): mixed
182+ {
183+ if ($value === null) {
184+ return null;
185+ }
186+
187+ return $this->delegate->serialize($value);
188+ }
189+
190+ public function deserialize(mixed $value): mixed
191+ {
192+ if ($value === null) {
193+ return null;
194+ }
195+
196+ return $this->delegate->deserialize($value);
197+ }
198+ }
199+ ```
200+
201+ In this example, ` NullableTypeAdapterFactory ` handles all nullable types. When a non-nullable
202+ type is given, it returns ` null ` . That means that the next in "queue" type adapter will be
203+ called. When a nullable is given, it returns a new type adapter instance which has two
204+ methods: ` serialize ` and ` deserialize ` . They do exactly what they're called.
205+
206+ ## Naming of keys
207+
208+ By default, serializer preserves the naming of keys, but this is easily customizable (in order of priority):
209+
210+ - specify a custom property name using the ` #[SerializedName] ` attribute
211+ - specify a custom naming strategy per class using the ` #[SerializedName] ` attribute
212+ - specify a custom global (default) naming strategy (use one of the built-in or write your own)
213+
214+ Here's an example:
215+
216+ ``` php
217+ (new SerializerBuilder())->namingStrategy(BuiltInNamingStrategy::SNAKE_CASE);
218+
219+ // Uses snake_case by default
220+ class Item1 {
221+ public function __construct(
222+ public int $keyName, // appears as "key_name" in serialized data
223+ #[SerializedName('second_key')] public int $firstKey, // second_key
224+ #[SerializedName(BuiltInNamingStrategy::PASCAL_CASE)] public int $thirdKey, // THIRD_KEY
225+ ) {}
226+ }
227+
228+ // Uses PASCAL_CASE by default
229+ #[SerializedName(BuiltInNamingStrategy::PASCAL_CASE)]
230+ class Item2 {
231+ public function __construct(
232+ public int $keyName, // KEY_NAME
233+ ) {}
234+ }
235+ ```
236+
237+ Out of the box, strategies for ` snake_case ` , ` camelCase ` and ` PascalCase ` are provided,
238+ but it's trivial to implement your own:
239+
240+ ``` php
241+ class PrefixedNaming implements NamingStrategy {
242+ public function __construct(
243+ private readonly string $prefix,
244+ ) {}
245+
246+ public function translate(PropertyReflection $property): string
247+ {
248+ return $this->prefix . $property->name();
249+ }
250+ }
251+
252+ #[SerializedName(new PrefixedNaming('$'))]
253+ class SiftTrackData {}
254+ ```
255+
256+ ## Required, nullable, optional and default values
257+
258+ By default, if a property is missing in serialized payload:
259+
260+ - nullable properties are just set to null
261+ - properties with a default value - use the default value
262+ - optional properties are set to ` MissingValue::INSTANCE `
263+ - any other throw an exception
264+
265+ Here's an example:
266+
267+ ``` php
268+ class Item {
269+ public function __construct(
270+ public ?int $first, // set to null
271+ public bool $second = true, // set to true
272+ public Item $third = new Item(...), // set to Item instance
273+ public int|MissingValue $fourth, // set to MissingValue::INSTANCE
274+ public int $fifth, // required, throws if missing
275+ ) {}
276+ }
277+
278+ // all keys missing -> throws for 'fifth' property
279+ $adapter->deserialize([])
280+
281+ // only required property -> uses null, default values and optional
282+ $adapter->deserialize(['fifth' => 123]);
283+
284+ // all properties -> fills all values
285+ $adapter->deserialize(['first' => 123, 'second' => false, ...]);
286+ ```
287+
288+ ## Flattening
289+
290+ Sometimes the same set of keys/types is shared between multiple other models. You could
291+ use inheritance for this, but we believe in composition over inheritance and hence provide
292+ a simple way to achieve the same behaviour without using inheritance:
293+
294+ Here's an example:
295+
296+ ``` php
297+ class Pagination {
298+ public function __construct(
299+ public readonly int $perPage,
300+ public readonly int $total,
301+ ) {}
302+ }
303+
304+ class UsersPaginatedList {
305+ public function __construct(
306+ #[Flatten]
307+ public readonly Pagination $pagination,
308+ /** @var User[] */
309+ public readonly array $users,
310+ ) {}
311+ }
312+
313+ // {"perPage": 25, "total": 100, "users": []}
314+ $adapter->serialize(
315+ new UsersPaginatedList(
316+ pagination: new Pagination(25, 100),
317+ users: [],
318+ )
319+ );
320+ ```
321+
322+ ## Use default value for unexpected
323+
324+ There are situations where you're deserializing data from a third party that doesn't have an API documentation
325+ or one that can't keep a backwards compatibility promise. One such case is when a third party uses an enum
326+ and you expect that new enum values might get added in the future by them. For example, imagine this structure:
327+
328+ ``` php
329+ enum CardType: string
330+ {
331+ case CLUBS = 'clubs';
332+ case DIAMONDS = 'diamonds';
333+ case HEARTS = 'hearts';
334+ case SPADES = 'spades';
335+ }
336+
337+ readonly class Card {
338+ public function __construct(
339+ public CardType $type,
340+ public string $value,
341+ ) {}
342+ }
343+ ```
344+
345+ If you get an unexpected value for ` type ` , you'll get an exception:
346+
347+ ``` php
348+ // UnexpectedEnumValueException: Expected one of [clubs, diamonds, hearts, spades], but got 'joker'
349+ $adapter->deserialize('{"type": "joker"}');
350+ ```
351+
352+ So if you suspect that might happen, add a default value you wish to use (anything) and
353+ a ` #[UseDefaultForUnexpected] ` attribute:
354+
355+ ``` php
356+ readonly class Card {
357+ public function __construct(
358+ #[UseDefaultForUnexpected]
359+ public CardType $type = null,
360+ // Can be any other valid default value
361+ #[UseDefaultForUnexpected]
362+ public CardType $type2 = CardType::SPADES,
363+ ) {}
364+ }
365+ ```
366+
367+ Whenever that happens, a default value will be used instead. Optionally, you can also log such cases:
368+
369+ ``` php
370+ $serializer = (new SerializerBuilder())
371+ ->reportUnexpectedDefault(function (BoundClassProperty $property, UnexpectedValueException $e) {
372+ $log->warning("Serializer used a default for unexpected value: {$e->getMessage()}", [
373+ 'property' => $property->serializedName(),
374+ 'exception' => $e,
375+ ]);
376+ })
377+ ->build();
378+ ```
379+
380+ ## Error handling
381+
382+ This is expected to be used with client-provided data, so good error descriptions is a must.
383+ These are some of the errors you'll get:
384+
385+ - Expected value of type 'int', but got 'string'
386+ - Expected value of type 'string', but got 'NULL'
387+ - Failed to parse time string (2020 dasd) at position 5 (d): The timezone could not be found in the database
388+ - Expected value of type 'string|int', but got 'boolean'
389+ - Expected one of [ one, two] , but got 'five'
390+ - Could not map item at key '1': Expected value of type 'string', but got 'NULL'
391+ - Could not map item at key '0': Expected value of type 'string', but got 'NULL' (and 1 more errors)."
392+ - Could not map property at path 'nested.field': Expected value of type 'string', but got 'integer'
393+
394+ All of these are just a chain of PHP exceptions with ` previous ` exceptions. Besides
395+ those messages, you have all of the thrown exceptions with necessary information.
396+
397+ ## More formats
398+
399+ You can add support for more formats as you wish with your own type adapters.
400+ All of the existing adapters are at your disposal:
401+
402+ ``` php
403+ interface XmlTypeAdapter extends TypeAdapter {}
404+
405+ final class FromPrimitiveXmlTypeAdapter implements XmlTypeAdapter
406+ {
407+ public function __construct(
408+ private readonly PrimitiveTypeAdapter $primitiveAdapter,
409+ ) {
410+ }
411+
412+ public function serialize(mixed $value): mixed
413+ {
414+ return xml_encode($this->primitiveAdapter->serialize($value));
415+ }
416+
417+ public function deserialize(mixed $value): mixed
418+ {
419+ return $this->primitiveAdapter->deserialize(xml_decode($value));
420+ }
421+ }
422+ ```
68423
69424## Why this over everything else?
70425
71- There are some alternatives to this, but they usually lack one of the following :
426+ There are some alternatives to this, but all of them will lack at least one of these :
72427
73- - stupid simple internal structure: no node tree, no value/JSON wrappers, no in-repo custom reflection implementation, no PHP parsing
74- - doesn't rely on inheritance of serializable classes, hence allows serializing third-party classes
428+ - doesn't rely on inheritance, hence allows serializing third-party classes
75429- parses existing PHPDoc information instead of duplicating it through attributes
76430- supports generic types which are quite useful for wrapper types
77431- allows simple extension through mappers and complex stuff through type adapters
78432- produces developer-friendly error messages for invalid data
79433- correctly handles optional (missing keys) and ` null ` values as separate concerns
80434- simple to extend with additional formats
435+ - simple internal structure: no node tree, no value/JSON wrappers, no custom reflection / PHP parsing, no inherent limitations
0 commit comments