Skip to content

feat: add a bunch of mapper/normalizer configurators - #829

Merged
romm merged 17 commits into
CuyZ:masterfrom
romm:feat/configurators
Jul 29, 2026
Merged

feat: add a bunch of mapper/normalizer configurators#829
romm merged 17 commits into
CuyZ:masterfrom
romm:feat/configurators

Conversation

@romm

@romm romm commented Jul 29, 2026

Copy link
Copy Markdown
Member

Provided mapper configurators

A set of configurators is now available out-of-the-box for the mapper, mirroring the normalizer configurators introduced in the previous release. Each one can be used either globally through the configureWith() method or locally as an attribute targeting a specific property.

The MapToDateTimeFromFormat configurator parses the input string using the given date format, which must follow the syntax supported by DateTimeImmutable::createFromFormat():

use CuyZ\Valinor\Mapper\Configurator\MapToDateTimeFromFormat;
use CuyZ\Valinor\MapperBuilder;
use DateTimeInterface;

final readonly class Event
{
    public function __construct(
        public string $name,

        #[MapToDateTimeFromFormat('d/m/Y')]
        public DateTimeInterface $date,
    ) {}
}

$event = (new MapperBuilder())
    ->mapper()
    ->map(Event::class, [
        'name' => 'Release of legendary album',
        'date' => '08/11/1971', // mapped to a `DateTimeImmutable`
    ]);

The MapExplodedStringToList configurator explodes a string into a list using the given separator, which is useful when the input carries a list as a single delimited string, for instance a value coming from a CSV file or a query parameter:

use CuyZ\Valinor\Mapper\Configurator\MapExplodedStringToList;
use CuyZ\Valinor\MapperBuilder;

final readonly class Product
{
    public function __construct(
        public string $name,

        /** @var list<string> */
        #[MapExplodedStringToList(separator: ',')]
        public array $sizes,
    ) {}
}

$product = (new MapperBuilder())
    ->mapper()
    ->map(Product::class, [
        'name' => 'T-Shirt',
        'sizes' => 'XS,S,M,L,XL', // mapped to `['XS', 'S', 'M', 'L', 'XL']`
    ]);

The MapArrayToList configurator discards the keys of an array and maps its values to a list, for cases where the input is an associative array, or a sparse list with missing or out-of-order indices, that should be handled as a sequential list:

use CuyZ\Valinor\Mapper\Configurator\MapArrayToList;
use CuyZ\Valinor\MapperBuilder;

final readonly class Basket
{
    public function __construct(
        /** @var list<string> */
        #[MapArrayToList]
        public array $products,
    ) {}
}

$basket = (new MapperBuilder())
    ->mapper()
    ->map(Basket::class, [
        'a' => 'Coffee',
        'b' => 'Tea',
    ]); // mapped to `['Coffee', 'Tea']`

Finally, the MapFromJson configurator decodes a JSON string and hands the result over to the mapper, so that the usual validation and error reporting still apply to the decoded value:

use CuyZ\Valinor\Mapper\Configurator\MapFromJson;
use CuyZ\Valinor\MapperBuilder;

final readonly class User
{
    public function __construct(
        public string $name,

        /** @var list<string> */
        #[MapFromJson]
        public array $roles,
    ) {}
}

$user = (new MapperBuilder())
    ->mapper()
    ->map(User::class, [
        'name' => 'John Doe',
        'roles' => '["admin", "editor"]', // mapped to `['admin', 'editor']`
    ]);

Scalar value casting

Four configurators convert a scalar value to a specific type before mapping: MapAsBool, MapAsInt, MapAsFloat and MapAsString. They are useful when the input data carries values in a different representation than the targeted type, for instance numbers or booleans encoded as strings in a form submission, a CSV file or a JSON payload.

Used as an attribute, a single property is cast, leaving the strictness rules untouched for every other value:

use CuyZ\Valinor\Mapper\Configurator\MapAsBool;
use CuyZ\Valinor\Mapper\Configurator\MapAsInt;
use CuyZ\Valinor\MapperBuilder;

final readonly class User
{
    public function __construct(
        public string $name,

        #[MapAsInt]
        public int $age,

        #[MapAsBool(true: ['on', 'yes'], false: ['off', 'no'])]
        public bool $isActive,
    ) {}
}

$user = (new MapperBuilder())
    ->mapper()
    ->map(User::class, [
        'name' => 'John Doe',
        'age' => '42', // mapped to `42`
        'isActive' => 'on', // mapped to `true`
    ]);

Casting can also be enabled for every value of a given type with the new allowCastingToBoolean(), allowCastingToInteger(), allowCastingToFloat() and allowCastingToString() methods of the mapper builder. They offer a finer control than allowScalarValueCasting(), which relaxes strictness for all scalar types at once:

use CuyZ\Valinor\MapperBuilder;

$age = (new MapperBuilder())
    ->allowCastingToInteger()
    ->mapper()
    ->map('int', '42'); // mapped to `42`

Mapping a property from a specific key

The new MapFromKey attribute feeds a class property, or a constructor/method argument, from a specific source key instead of matching it against the property name:

use CuyZ\Valinor\Mapper\Configurator\MapFromKey;
use CuyZ\Valinor\MapperBuilder;

final readonly class Person
{
    public function __construct(
        public string $name,

        #[MapFromKey('zipCode')]
        public string $postalCode,
    ) {}
}

$person = (new MapperBuilder())
    ->mapper()
    ->map(Person::class, [
        'name' => 'John Doe',
        'zipCode' => '75001', // mapped to `$postalCode`
    ]);

This attribute is built on a lightweight protocol that is open to userland: any attribute class declaring a mapKey(string $key): string method and carrying the #[AsConverter] attribute can remap the key of the element it is placed on. This is handy to factor out a recurring transformation, such as a prefix shared by several properties:

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_PARAMETER)]
#[\CuyZ\Valinor\Mapper\AsConverter]
final class MapWithPrefix
{
    public function __construct(private string $prefix) {}

    public function mapKey(string $key): string
    {
        return $this->prefix . $key;
    }
}

final readonly class Configuration
{
    public function __construct(
        #[MapWithPrefix('app_')] // reads from `app_host`
        public string $host,
        #[MapWithPrefix('app_')] // reads from `app_port`
        public int $port,
    ) {}
}

New normalizer configurators

Three configurators join the ones introduced in the previous release.

The NormalizeKeyTo attribute renames the key of a property during normalization, when the name used in the data format differs from the one used in the PHP codebase:

use CuyZ\Valinor\Normalizer\Configurator\NormalizeKeyTo;
use CuyZ\Valinor\Normalizer\Format;
use CuyZ\Valinor\NormalizerBuilder;

final readonly class Address
{
    public function __construct(
        public string $street,

        #[NormalizeKeyTo('town')]
        public string $city,
    ) {}
}

$addressAsArray = (new NormalizerBuilder())
    ->normalizer(Format::array())
    ->normalize(new Address('221B Baker Street', 'London'));

// [
//     'street' => '221B Baker Street',
//     'town' => 'London',
// ]

The NormalizeToSingleValue class flattens an object holding a single property, so that instead of ['someProperty' => 'value'] the normalized result is simply 'value'. It can be used either as a configurator, applying to every object with a single property, or as an attribute targeting a specific class or property:

use CuyZ\Valinor\Normalizer\Configurator\NormalizeToSingleValue;
use CuyZ\Valinor\Normalizer\Format;
use CuyZ\Valinor\NormalizerBuilder;

final readonly class Email
{
    public function __construct(
        public string $email,
    ) {}
}

final readonly class User
{
    public function __construct(
        public string $name,

        #[NormalizeToSingleValue]
        public Email $email,
    ) {}
}

$userAsArray = (new NormalizerBuilder())
    ->normalizer(Format::array())
    ->normalize(new User('John Doe', new Email('john.doe@example.com')));

// [
//     'name' => 'John Doe',
//     'email' => 'john.doe@example.com',
// ]

The IgnoreOnNormalization attribute excludes a property from the normalized output, for instance to hide sensitive data such as a password. For the attribute to take effect, an instance of this class must also be registered on the builder via configureWith():

use CuyZ\Valinor\Normalizer\Configurator\IgnoreOnNormalization;
use CuyZ\Valinor\Normalizer\Format;
use CuyZ\Valinor\NormalizerBuilder;

final readonly class User
{
    public function __construct(
        public string $name,

        #[IgnoreOnNormalization]
        public string $password,
    ) {}
}

$userAsArray = (new NormalizerBuilder())
    ->configureWith(new IgnoreOnNormalization())
    ->normalizer(Format::array())
    ->normalize(new User('John Doe', 's3cr3t'));

// ['name' => 'John Doe']

romm added 17 commits July 29, 2026 22:23
Renames the key of a property during normalization. This is useful when
the name of a property in the data format differs from the one used in
the PHP codebase.

 ```php
 use CuyZ\Valinor\Normalizer\Configurator\NormalizeKeyTo;
 use CuyZ\Valinor\Normalizer\Format;
 use CuyZ\Valinor\NormalizerBuilder;

 final readonly class Address
 {
     public function __construct(
         public string $street,
         public string $zipCode,
         #[NormalizeKeyTo('town')]
         public string $city,
     ) {}
 }

 $addressAsArray = (new NormalizerBuilder())
     ->normalizer(Format::array())
     ->normalize(new Address('221B Baker Street', 'NW1 6XE', 'London'));

 // [
 //     'street' => '221B Baker Street',
 //     'zipCode' => 'NW1 6XE',
 //     'town' => 'London',
 // ]
 ```

The manual "Renaming properties" normalizer documentation example now
points to this configurator chapter.
Flattens objects that hold a single property, so that instead of
`['someProperty' => 'value']` the normalized result is simply `'value'`.

This class can be used either as a configurator for global usage or as
an attribute to target a specific class or property.

Global usage as a configurator
------------------------------

 ```php
 use CuyZ\Valinor\Normalizer\Configurator\NormalizeToSingleValue;
 use CuyZ\Valinor\Normalizer\Format;
 use CuyZ\Valinor\NormalizerBuilder;

 final readonly class Email
 {
     public function __construct(
         public string $email,
     ) {}
 }

 // Every object with a single property is flattened
 $value = (new NormalizerBuilder())
     ->configureWith(new NormalizeToSingleValue())
     ->normalizer(Format::array())
     ->normalize(new Email('john.doe@example.com'));

 // 'john.doe@example.com'
 ```

Local usage as an attribute
---------------------------

 ```php
 use CuyZ\Valinor\Normalizer\Configurator\NormalizeToSingleValue;
 use CuyZ\Valinor\Normalizer\Format;
 use CuyZ\Valinor\NormalizerBuilder;

 final readonly class Email
 {
     public function __construct(
         public string $email,
     ) {}
 }

 // Only the targeted property is flattened
 final readonly class User
 {
     public function __construct(
         public string $name,
         #[NormalizeToSingleValue]
         public Email $email,
     ) {}
 }

 $userAsArray = (new NormalizerBuilder())
     ->normalizer(Format::array())
     ->normalize(new User('John Doe', new Email('john.doe@example.com')));

 // [
 //     'name' => 'John Doe',
 //     'email' => 'john.doe@example.com',
 // ]
 ```

The manual "Flattening single property objects" normalizer documentation
example now points to this configurator chapter.
Excludes a property from the normalized output, for instance to hide
sensitive data such as a password.

For the attribute to take effect, an instance of this class must also be
registered on the normalizer builder via `configureWith()`. Without this
registration the property is not ignored.

 ```php
 use CuyZ\Valinor\Normalizer\Configurator\IgnoreOnNormalization;
 use CuyZ\Valinor\Normalizer\Format;
 use CuyZ\Valinor\NormalizerBuilder;

 final readonly class User
 {
     public function __construct(
         public string $name,

         #[IgnoreOnNormalization]
         public string $password,
     ) {}
 }

 $userAsArray = (new NormalizerBuilder())
     ->configureWith(new IgnoreOnNormalization())
     ->normalizer(Format::array())
     ->normalize(new User('John Doe', 's3cr3t'));

 // ['name' => 'John Doe']
 ```
Shaped arrays and HTTP requests now resolve through a single
KeyConverterNodeBuilder -> ShapedArrayNodeBuilder ->
HttpRequestNodeBuilder chain instead of branching on the value type in
TypeNodeBuilder, with key conversion always wired in.
Source keys can now be remapped to a specific property or argument using
attributes: either the provided `MapFromKey` attribute or any custom
attribute implementing the key-mapping protocol.

`MapFromKey` feeds a class property, or a constructor/method argument,
from a specific source key instead of matching it against the property
name. This is useful when the source data uses a key that differs from
the name of the property it should be mapped to:

```php
use CuyZ\Valinor\Mapper\Configurator\MapFromKey;
use CuyZ\Valinor\MapperBuilder;

final readonly class Person
{
    public function __construct(
        public string $name,
        #[MapFromKey('zipCode')]
        public string $postalCode,
    ) {}
}

$person = (new MapperBuilder())
    ->mapper()
    ->map(Person::class, [
        'name' => 'John Doe',
        'zipCode' => '75001',
    ]);

// $person->postalCode === '75001';
```

The given key is read as-is: it is not affected by the key converters
registered with `registerKeyConverter()`, and the property name is no
longer accepted, the source is read only from the given key. It also
works when mapping an HTTP request, alongside the `#[FromRoute]`,
`#[FromQuery]` and `#[FromBody]` attributes.

More generally, any attribute class declaring a `mapKey(string): string`
method, and flagged with `#[AsConverter]`, can remap the key of the
element it is placed on, for instance to factor out a recurring
transformation such as a shared prefix:

```php
final class MapWithPrefix
{
    public function __construct(private string $prefix) {}

    public function mapKey(string $key): string
    {
        return $this->prefix . $key;
    }
}
```
Converts the given string and integer representations to a real `bool`
before mapping. By default `1`, `'1'` and `'true'` are converted to
`true`, and `0`, `'0'` and `'false'` to `false`.

 ```php
 use CuyZ\Valinor\Mapper\Configurator\MapAsBool;
 use CuyZ\Valinor\MapperBuilder;

 final readonly class User
 {
     public function __construct(
         public string $name,

         #[MapAsBool]
         public bool $isActive,
     ) {}
 }

 $user = (new MapperBuilder())
     ->mapper()
     ->map(User::class, [
         'name' => 'John Doe',
         'isActive' => 'true', // mapped to `true`
     ]);
 ```

The accepted representations can be customized, for instance to also
recognize `'on'` and `'off'`:

 ```php
 #[MapAsBool(true: ['on', 'yes'], false: ['off', 'no'])]
 public bool $isActive;
 ```

The manual "Casting to boolean" converter example now points to this
configurator chapter.
Converts a string representation of an integer to a real `int` before
mapping. Any value that is not a valid integer representation is left
untouched and handed over to the mapper, which will raise an error if it
cannot be mapped to an integer.

 ```php
 use CuyZ\Valinor\Mapper\Configurator\MapAsInt;
 use CuyZ\Valinor\MapperBuilder;

 final readonly class User
 {
     public function __construct(
         public string $name,

         #[MapAsInt]
         public int $age,
     ) {}
 }

 $user = (new MapperBuilder())
     ->mapper()
     ->map(User::class, [
         'name' => 'John Doe',
         'age' => '42', // mapped to `42`
     ]);
 ```

The manual "Casting to integer" converter example now points to this
configurator chapter.
Converts a string representation of a number to a real `float` before
mapping. Any value that is not a valid number representation is left
untouched and handed over to the mapper, which will raise an error if it
cannot be mapped to a float.

 ```php
 use CuyZ\Valinor\Mapper\Configurator\MapAsFloat;
 use CuyZ\Valinor\MapperBuilder;

 final readonly class Product
 {
     public function __construct(
         public string $name,

         #[MapAsFloat]
         public float $price,
     ) {}
 }

 $product = (new MapperBuilder())
     ->mapper()
     ->map(Product::class, [
         'name' => 'Coffee',
         'price' => '4.50', // mapped to `4.5`
     ]);
 ```

The manual "Casting to float" converter example now points to this
configurator chapter.
Converts an integer or a float to a `string` before mapping. This is
useful when the input data carries numbers that must be handled as
strings, for instance an identifier or a postal code.

 ```php
 use CuyZ\Valinor\Mapper\Configurator\MapAsString;
 use CuyZ\Valinor\MapperBuilder;

 final readonly class User
 {
     public function __construct(
         public string $name,

         #[MapAsString]
         public string $id,
     ) {}
 }

 $user = (new MapperBuilder())
     ->mapper()
     ->map(User::class, [
         'name' => 'John Doe',
         'id' => 42, // mapped to `'42'`
     ]);
 ```

The manual "Casting to string" converter example now points to this
configurator chapter.
Parses the input string using the given date format before mapping. This
is useful when the input data carries a date in a specific format that
the mapper would not otherwise recognize.

The format must follow the syntax supported by
`DateTimeImmutable::createFromFormat()`. A value that does not match the
given format raises a mapping error.

 ```php
 use CuyZ\Valinor\Mapper\Configurator\MapToDateTimeFromFormat;
 use CuyZ\Valinor\MapperBuilder;
 use DateTimeInterface;

 final readonly class Event
 {
     public function __construct(
         public string $name,

         #[MapToDateTimeFromFormat('d/m/Y')]
         public DateTimeInterface $date,
     ) {}
 }

 $event = (new MapperBuilder())
     ->mapper()
     ->map(Event::class, [
         'name' => 'Release of legendary album',
         'date' => '08/11/1971', // mapped to a `DateTimeImmutable`
     ]);
 ```

The error message raised on failure is registered in the default
translations, and the manual "Custom datetime format" converter example
now points to this configurator chapter.
Discards the keys of an array and maps its values to a list before
mapping. This is useful when the input data is an associative array, or a
sparse list with missing or out-of-order indices, that should be handled
as a sequential list.

 ```php
 use CuyZ\Valinor\Mapper\Configurator\MapArrayToList;
 use CuyZ\Valinor\MapperBuilder;

 final readonly class Basket
 {
     public function __construct(
         /** @var list<string> */
         #[MapArrayToList]
         public array $products,
     ) {}
 }

 $basket = (new MapperBuilder())
     ->mapper()
     ->map(Basket::class, [
         'a' => 'Coffee',
         'b' => 'Tea',
     ]); // mapped to `['Coffee', 'Tea']`
 ```

The manual "Array to list" converter example now points to this
configurator chapter.
Decodes a JSON string and hands the result over to the mapper. This is
useful when the input data carries a nested structure as an encoded JSON
string, for instance a column stored in a database or a field in a form
submission.

The decoded value is then mapped against the targeted type, so the usual
validation and error reporting still apply. An invalid JSON string raises
a mapping error.

 ```php
 use CuyZ\Valinor\Mapper\Configurator\MapFromJson;
 use CuyZ\Valinor\MapperBuilder;

 final readonly class User
 {
     public function __construct(
         public string $name,

         /** @var list<string> */
         #[MapFromJson]
         public array $roles,
     ) {}
 }

 $user = (new MapperBuilder())
     ->mapper()
     ->map(User::class, [
         'name' => 'John Doe',
         'roles' => '["admin", "editor"]', // mapped to `['admin', 'editor']`
     ]);
 ```

The error message raised on invalid JSON is registered in the default
translations, and the manual "Json decode" converter example now points
to this configurator chapter.
Explodes a string into a list using the given separator before mapping.
This is useful when the input data carries a list as a single delimited
string, for instance a comma-separated value coming from a CSV file or a
query parameter.

The resulting list is then mapped against the targeted type, so the items
can be cast further, for instance to a `list<int>`.

 ```php
 use CuyZ\Valinor\MapperBuilder;
 use CuyZ\Valinor\Mapper\Configurator\MapExplodedStringToList;

 final readonly class Product
 {
     public function __construct(
         public string $name,

         #[MapExplodedStringToList(separator: ',')]
         /** @var list<string> */
         public array $sizes,
     ) {}
 }

 $product = (new MapperBuilder())
     ->mapper()
     ->map(Product::class, [
         'name' => 'T-Shirt',
         'sizes' => 'XS,S,M,L,XL', // mapped to `['XS', 'S', 'M', 'L', 'XL']`
     ]);
 ```
� Conflicts:
�	src/Type/Types/BooleanValueType.php
�	src/Type/Types/ClassStringType.php
�	src/Type/Types/FloatValueType.php
�	src/Type/Types/IntegerRangeType.php
�	src/Type/Types/IntegerValueType.php
�	src/Type/Types/NativeBooleanType.php
�	src/Type/Types/NativeFloatType.php
�	src/Type/Types/NativeIntegerType.php
�	src/Type/Types/NativeStringType.php
�	src/Type/Types/NegativeIntegerType.php
�	src/Type/Types/NonEmptyStringType.php
�	src/Type/Types/NonNegativeIntegerType.php
�	src/Type/Types/NonPositiveIntegerType.php
�	src/Type/Types/NumericStringType.php
�	src/Type/Types/PositiveIntegerType.php
�	src/Type/Types/ScalarConcreteType.php
�	src/Type/Types/StringValueType.php
The converter examples were only pointing to the provided mapper
configurators and are dropped entirely.

Most transformer examples likewise pointed to provided normalizer
configurators. The two remaining ones that cannot be provided
out-of-the-box, custom object normalization and API versioning, are moved
into the provided normalizer configurators page and clearly marked as
custom, non-provided examples.
@romm
romm force-pushed the feat/configurators branch from 4a29098 to 2f6ceb9 Compare July 29, 2026 22:40
@romm
romm merged commit f705d4c into CuyZ:master Jul 29, 2026
16 checks passed
@romm
romm deleted the feat/configurators branch July 29, 2026 22:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant