Skip to content

Update dependency cuyz/valinor to v2 - #485

Open
renovate[bot] wants to merge 1 commit into
masterfrom
renovate/cuyz-valinor-2.x
Open

Update dependency cuyz/valinor to v2#485
renovate[bot] wants to merge 1 commit into
masterfrom
renovate/cuyz-valinor-2.x

Conversation

@renovate

@renovate renovate Bot commented Nov 25, 2025

Copy link
Copy Markdown
Contributor

ℹ️ Note

This PR body was truncated due to platform limits.

This PR contains the following updates:

Package Change Age Adoption Passing Confidence
cuyz/valinor 1.17.02.6.0 age adoption passing confidence

Release Notes

CuyZ/Valinor (cuyz/valinor)

v2.6.0

Compare Source

Notable changes

This release brings a set of new features to the library:

Enjoy! 🎉


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']

Generics of PHP internal classes

Generics used to be limited to userland classes, because classes internal to PHP or provided by an extension cannot declare @template annotations in their own source code. The library now ships generic signatures for a wide range of them, including ArrayObject, ArrayIterator, the SPL data structures and the Ds collection classes, so they can be parameterized like any other class:

use CuyZ\Valinor\MapperBuilder;

$sizes = (new MapperBuilder())
    ->mapper()
    ->map('ArrayObject<string, int>', [
        'S' => 36,
        'M' => 38,
        'L' => 40,
    ]);

Every one of these templates declares a default type, so bare references like ArrayObject keep resolving as before.


Default types for templates

A @template annotation can now declare a default type with =. A template that declares a default type may be omitted when the class is referenced, in which case the default type is used:

/**
 * @template TValue
 * @template TMeta of array<string, mixed> = array<string, string>
 */
final readonly class Page
{
    public function __construct(
        /** @var list<TValue> */
        public array $items,

        /** @var TMeta */
        public array $meta,
    ) {}
}

final readonly class SomeClass
{
    public function __construct(
        // `TMeta` is not filled in, its default type is used
        /** @var Page<string> */
        public Page $pageWithDefaultMeta,

        // `TMeta` is filled in, overriding its default type
        /** @var Page<string, array{cursor: int}> */
        public Page $pageWithCursorMeta,
    ) {}
}

A default type is what makes it possible to add a template to a class that is already referenced elsewhere: the existing references, which do not fill the new template in, keep resolving to its default type and can be made more precise later on.


Overriding an unparseable type

When a property, parameter or return type uses a PHPStan or Psalm syntax that the library cannot parse yet, for instance a conditional type like ($a is 1 ? int : null), the dedicated @valinor-var, @valinor-param and @valinor-return annotations can be used to give the library a type it understands. They take precedence over every other annotation, so the static analysis tools keep using their own type while the library uses the override:

final class SomeClass
{
    /**
     * @phpstan-param ($a is 1 ? int : null) $b
     * @valinor-param int|null $b
     */
    public function __construct(
        public readonly int $a,
        public readonly ?int $b,
    ) {}
}

Features
  • Add @valinor-* annotations to override an unparseable type (11938c)
  • Add default value support for @template annotations (0d6efe)
  • Add mapper builder methods to cast to scalar types (cdca3f)
  • Add mapper configurator MapArrayToList (1f81fa)
  • Add mapper configurator MapAsBool (65dfed)
  • Add mapper configurator MapAsFloat (84eea9)
  • Add mapper configurator MapAsInt (ea28a7)
  • Add mapper configurator MapAsString (6b0528)
  • Add mapper configurator MapExplodedStringToList (beb4db)
  • Add mapper configurator MapFromJson (469863)
  • Add mapper configurator MapToDateTimeFromFormat (d6e53b)
  • Add normalizer configurator IgnoreOnNormalization (9769f2)
  • Add normalizer configurator NormalizeKeyTo (947127)
  • Add normalizer configurator NormalizeToSingleValue (7c5f13)
  • Allow mapping source keys with attributes (631f66)
  • Support generics of PHP internal classes (5419b4)
Bug Fixes
  • Bind the templates a constructor declares to the type being mapped (0629d8)
Internal
  • Refactor HTTP request mapping (578bd5)
  • Remove canCast() and cast() from scalar types (eae3f0)
  • Unify shaped array and HTTP request node building (3bb83b)

v2.5.1

Compare Source

Bug Fixes
  • Prevent union collision caused by absent optional elements (81b098)
  • Resolve docblock types of anonymous classes (7fc482)
  • Resolve members declared in parent interfaces (d46acf)
Internal
  • Refactor compiler node usage (166cde)

v2.5.0

Compare Source

Notable changes

This release brings a set of new features to the library:

Enjoy! 🎉


Normalizer configurators support

A set of configurators is now available for the normalizer, mirroring the mapper 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 class or property.

Keys case normalization

Four configurators normalize the keys of a normalized object to a given case. This is useful to expose data following a naming convention that differs from the one used in the PHP codebase.

Configurator Example
new NormalizeKeysToCamelCase() first_namefirstName
new NormalizeKeysToPascalCase() first_nameFirstName
new NormalizeKeysToSnakeCase() firstNamefirst_name
new NormalizeKeysToKebabCase() firstNamefirst-name

Used globally, the keys of every normalized object are converted:

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

$userAsArray = (new NormalizerBuilder())
    ->configureWith(new NormalizeKeysToSnakeCase())
    ->normalizer(Format::array())
    ->normalize($user);

// ['first_name' => 'John']

Used as an attribute, only the keys of the targeted class are converted:

use CuyZ\Valinor\Normalizer\Configurator\NormalizeKeysToSnakeCase;

#[NormalizeKeysToSnakeCase]
final readonly class User
{
    public function __construct(
        public string $firstName,
    ) {}
}

// ['first_name' => 'John']
Date and time normalization

The NormalizeDateTimeFormat configurator normalizes any DateTimeInterface instance to a string using the given format.

Used globally, every date and time encountered during normalization is formatted:

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

$userAsArray = (new NormalizerBuilder())
    ->configureWith(new NormalizeDateTimeFormat(DATE_ATOM))
    ->normalizer(Format::array())
    ->normalize($user);

// [
//     'name' => 'Jane Doe',
//     'createdAt' => '2000-01-01T00:00:00+00:00',
// ]

Used as an attribute, only the targeted property is formatted:

use CuyZ\Valinor\Normalizer\Configurator\NormalizeDateTimeFormat;

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

        #[NormalizeDateTimeFormat(DATE_ATOM)]
        public DateTimeInterface $createdAt,
    ) {}
}

Shaped list type support

The shaped list type list{…} is now supported. It works like a shaped array but enforces sequential integer keys starting at 0, making it the right type to describe a tuple-like list of values.

final readonly class SomeClass
{
    public function __construct(
        /** @var list{string, int, float} */
        public array $shapedList,

        /** @var list{0: string, 1: int} */
        public array $shapedListWithExplicitKeys,

        /** @var list{0: string, 1?: int} */
        public array $shapedListWithOptionalElement,

        /** @var list{string, int, ...} */
        public array $unsealedShapedList,

        /** @var list{string, int, ...list<float>} */
        public array $unsealedShapedListWithExplicitType,

        /** @var list{string, ...<float>} */
        public array $unsealedShapedListWithShorthandType,
    ) {}
}

key-of type support

The key-of<T> type is now supported. It extracts the key types from enums, arrays, lists, and shaped arrays, including array constants. It is compatible with the same syntax as accepted by PHPStan and Psalm.

enum SomeBackedEnum: string
{
    case FOO = 'foo';
    case BAR = 'bar';
}

final readonly class SomeClassWithConstants
{
    public const SOME_ARRAY = ['foo' => 1, 'bar' => 2];
}

final readonly class SomeClass
{
    public function __construct(
        // Accepts 'FOO' or 'BAR' (the case names of the enum)
        /** @var key-of<SomeBackedEnum> */
        public string $enumKey,

        // Accepts 'foo' or 'bar' (the keys of the shaped array)
        /** @var key-of<array{foo: string, bar: int}> */
        public string $shapedArrayKey,

        // Accepts the key type of the array (string here)
        /** @var key-of<array<string, int>> */
        public string $arrayKey,

        // Accepts 'foo' or 'bar' (the keys of the class constant array)
        /** @var key-of<SomeClassWithConstants::SOME_ARRAY> */
        public string $constantArrayKey,
    ) {}
}
Features
  • Add normalizer configurator ConvertDateTime (bf688b)
  • Add normalizer configurator NormalizeKeysToCamelCase (b0d38f)
  • Add normalizer configurator NormalizeKeysToKebabCase (9826ca)
  • Add normalizer configurator NormalizeKeysToPascalCase (53bff2)
  • Add normalizer configurator NormalizeKeysToSnakeCase (c831e0)
  • Add support for key-of type mapping (ff16b2)
  • Add support for covariant templates (c31f24)
  • Add support for shaped list type (eeeb5c)
  • Support local alias types referencing other local aliases (757256)
  • Support null values for class constants (5cd356)
  • Support parenthesized union types (21a04b)
Bug Fixes
  • Prevent memory leak with functions' reflection (6d36a0)
  • Rank union candidates by matching arguments (126cf7)
Internal
  • Add security vulnerability reporting guidelines (b80e2a)
  • Memoize parent class definitions (17d8cf)
  • Move int to float casting outside Shell (f84e78)
Other
  • Rename ConvertDateTime configurator to NormalizeDateTimeFormat (b7683a)
  • Rename ConvertKeysTo*Case configurators to MapKeysTo*Case (e38e06)

v2.4.0

Compare Source

Notable changes

This release brings a whole set of new features to the library:

Enjoy! 🎉


HTTP request mapping support

This library now provides a way to map an HTTP request to controller action parameters or object properties. Parameters can be mapped from route, query and body values.

Three attributes are available to explicitly bind a parameter to a single source, ensuring the value is never resolved from the wrong source:

  • #[FromRoute] — for parameters extracted from the URL path by router
  • #[FromQuery] — for query string parameters
  • #[FromBody] — for request body values

Those attributes can be omitted entirely if the parameter is not bound to a specific source, in which case a collision error is raised if the same key is found in more than one source.

This gives controllers a clean, type-safe signature without coupling to a framework's request object, while benefiting from the library's validation and error handling.

Normal mapping rules apply there: parameters are required unless they have a default value.

Route and query parameter values coming from an HTTP request are typically strings. The mapper automatically handles scalar value casting for these parameters: a string "42" will be properly mapped to an int parameter.

Mapping a request using attributes

Consider an API that lists articles for a given author. The author identifier comes from the URL path, while filtering and pagination come from the query string.

use CuyZ\Valinor\Mapper\Http\FromQuery;
use CuyZ\Valinor\Mapper\Http\FromRoute;
use CuyZ\Valinor\Mapper\Http\HttpRequest;
use CuyZ\Valinor\MapperBuilder;

final class ListArticles
{
    /**
     * GET /api/authors/{authorId}/articles?status=X&page=X&limit=X
     *
     * @param non-empty-string $page
     * @param positive-int $page
     * @param int<10, 100> $limit
     */
    public function __invoke(
        // Comes from the route
        #[FromRoute] string $authorId,

        // All come from query parameters
        #[FromQuery] string $status,
        #[FromQuery] int $page = 1,
        #[FromQuery] int $limit = 10,
    ): ResponseInterface { … }
}

// GET /api/authors/42/articles?status=published&page=2
$request = new HttpRequest(
    routeParameters: ['authorId' => 42],
    queryParameters: [
        'status' => 'published',
        'page' => 2,
    ],
);

$controller = new ListArticles();

$arguments = (new MapperBuilder())
    ->argumentsMapper()
    ->mapArguments($controller, $request);

$response = $controller(...$arguments);
Mapping a request without using attributes

When it is unnecessary to distinguish which source a parameter comes from, the attribute can be omitted entirely — the mapper will resolve each parameter from whichever source contains the matching key.

use CuyZ\Valinor\Mapper\Http\HttpRequest;
use CuyZ\Valinor\MapperBuilder;

final class PostComment
{
    /**
     * POST /api/posts/{postId}/comments
     *
     * @param non-empty-string $author
     * @param non-empty-string $content
     */
    public function __invoke(
        int $postId,
        string $author,
        string $content,
    ): ResponseInterface { … }
}

// POST /api/posts/1337/comments
$request = new HttpRequest(
    routeParameters: ['postId' => 1337],
    bodyValues: [
        'author' => 'jane.doe@example.com',
        'content' => 'Great article, thanks for sharing!',
    ],
);

$controller = new PostComment();

$arguments = (new MapperBuilder())
    ->argumentsMapper()
    ->mapArguments($controller, $request);

$response = $controller(...$arguments);

[!NOTE]

If the same key is found in more than one source for a parameter that has no attribute, a collision error is raised.

Mapping all parameters at once

Instead of mapping individual query parameters or body values to separate parameters, the asRoot option can be used to map all of them at once to a single parameter. This is useful when working with complex data structures or when the number of parameters is large.

use CuyZ\Valinor\Mapper\Http\FromQuery;
use CuyZ\Valinor\Mapper\Http\FromRoute;

final readonly class ArticleFilters
{
    public function __construct(
        /** @var non-empty-string */
        public string $status,
        /** @var positive-int */
        public int $page = 1,
        /** @var int<10, 100> */
        public int $limit = 10,
    ) {}
}

final class ListArticles
{
    /**
     * GET /api/authors/{authorId}/articles?status=X&page=X&limit=X
     */
    public function __invoke(
        #[FromRoute] string $authorId,
        #[FromQuery(asRoot: true)] ArticleFilters $filters,
    ): ResponseInterface { … }
}

The same approach works with #[FromBody(asRoot: true)] for body values.

[!TIP]

A shaped array can be used alongside asRoot to map all values to a single parameter:

use CuyZ\Valinor\Mapper\Http\FromQuery;
use CuyZ\Valinor\Mapper\Http\FromRoute;

final class ListArticles
{
    /**
     * GET /api/authors/{authorId}/articles?status=X&&page=X&limit=X
     *
     * @param array{
     *     status: non-empty-string,
     *     page?: positive-int,
     *     limit?: int<10, 100>,
     * } $filters
     */
    public function __invoke(
        #[FromRoute] string $authorId,
        #[FromQuery(asRoot: true)] array $filters,
    ): ResponseInterface { … }
}
Mapping to an object

Instead of mapping to a callable's arguments, an HttpRequest can be mapped directly to an object. The attributes work the same way on constructor parameters or promoted properties.

use CuyZ\Valinor\Mapper\Http\FromBody;
use CuyZ\Valinor\Mapper\Http\FromRoute;
use CuyZ\Valinor\Mapper\Http\HttpRequest;
use CuyZ\Valinor\MapperBuilder;

final readonly class PostComment
{
    public function __construct(
        #[FromRoute] public int $postId,
        /** @var non-empty-string */
        #[FromBody] public string $author,
        /** @var non-empty-string */
        #[FromBody] public string $content,
    ) {}
}

$request = new HttpRequest(
    routeParameters: ['postId' => 1337],
    bodyValues: [
        'author' => 'jane.doe@example.com',
        'content' => 'Great article, thanks for sharing!',
    ],
);

$comment = (new MapperBuilder())
    ->mapper()
    ->map(PostComment::class, $request);

// $comment->postId  === 1337
// $comment->author  === 'jane.doe@example.com'
// $comment->content === 'Great article, thanks for sharing!'
Using PSR-7 requests

An HttpRequest instance can be built directly from a PSR-7 ServerRequestInterface. This is the recommended approach when integrating with frameworks that use PSR-7.

use CuyZ\Valinor\Mapper\Http\HttpRequest;
use CuyZ\Valinor\MapperBuilder;

// `$psrRequest` is a PSR-7 `ServerRequestInterface` instance
// `$routeParameters` are the parameters extracted by the router
$request = HttpRequest::fromPsr($psrRequest, $routeParameters);

$arguments = (new MapperBuilder())
    ->argumentsMapper()
    ->mapArguments($controller, $request);

The factory method extracts query parameters from getQueryParams() and body values from getParsedBody(). It also passes the original PSR-7 request object through, so it can be injected into controller parameters if needed (see below).

Accessing the original request object

When building an HttpRequest, an original request object can be provided. If a controller parameter's type matches this object, it will be injected automatically; no attribute is needed.

use CuyZ\Valinor\Mapper\Http\FromRoute;
use CuyZ\Valinor\Mapper\Http\HttpRequest;
use CuyZ\Valinor\MapperBuilder;
use Psr\Http\Message\ServerRequestInterface;

final class ListArticles
{
    /**
     * GET /api/authors/{authorId}/articles
     */
    public function __invoke(
        // Request object injected automatically
        ServerRequestInterface $request,

        #[FromRoute] string $authorId,
    ): ResponseInterface {
        $acceptHeader = $request->getHeaderLine('Accept');

        // …
    }
}

$request = HttpRequest::fromPsr($psrRequest, $routeParameters);

$arguments = (new MapperBuilder())
    ->argumentsMapper()
    ->mapArguments(new ListArticles(), $request);

// $arguments['request'] is the original PSR-7 request instance
Error handling

When the mapping fails — for instance because a required query parameter is missing or a body value has the wrong type — a MappingError is thrown, just like with regular mapping.

Read the validation and error handling chapter for more information.


Mapper/Normalizer configurators support

Introduce MapperBuilderConfigurator and NormalizerBuilderConfigurator interfaces along with a configureWith() method on both builders.

A configurator is a reusable piece of configuration logic that can be applied to a MapperBuilder or a NormalizerBuilder instance. This is useful when the same configuration needs to be applied in multiple places across an application, or when configuration logic needs to be distributed as a package.

In the example below, we apply two configuration settings to a MapperBuilder inside a single class, but this could contain any number of customizations, depending on the needs of the application.

namespace My\App;

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

final class ApplicationMappingConfigurator implements MapperBuilderConfigurator
{
    public function configureMapperBuilder(MapperBuilder $builder): MapperBuilder
    {
        return $builder
            ->allowSuperfluousKeys()
            ->registerConstructor(
                \My\App\CustomerId::fromString(...),
            );
    }
}

This configurator can be registered within the MapperBuilder instance:

$result = (new \CuyZ\Valinor\MapperBuilder())
    ->configureWith(new \My\App\ApplicationMappingConfigurator())
    ->mapper()
    ->map(\My\App\User::class, [
        'id' => '604e4b36-5b76-4b1a-9e6c-02d5acb53a4d',
        'name' => 'John Doe',
        'extraField' => 'ignored because superfluous keys are allowed',
    ]);
Composing multiple configurators

Multiple configurators can be combined to compose the final configuration. Each configurator is applied in order, allowing layered and modular configuration.

namespace My\App;

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

final class FlexibleMappingConfigurator implements MapperBuilderConfigurator
{
    public function configureMapperBuilder(MapperBuilder $builder): MapperBuilder
    {
        return $builder
            ->allowScalarValueCasting()
            ->allowSuperfluousKeys();
    }
}

final class DomainConstructorsConfigurator implements MapperBuilderConfigurator
{
    public function configureMapperBuilder(MapperBuilder $builder): MapperBuilder
    {
        return $builder
            ->registerConstructor(
                \My\App\CustomerId::fromString(...),
                \My\App\Email::fromString(...),
            );
    }
}

$result = (new \CuyZ\Valinor\MapperBuilder())
    ->configureWith(
        new \My\App\FlexibleMappingConfigurator(),
        new \My\App\DomainConstructorsConfigurator(),
    )
    ->mapper()
    ->map(\My\App\User::class, $someData);

This approach keeps each configurator focused on a single concern, making them easier to test and reuse independently.

Using NormalizerBuilderConfigurator

The same configurator logic can be applied on NormalizerBuilder:

namespace My\App;

use CuyZ\Valinor\NormalizerBuilder;
use CuyZ\Valinor\Normalizer\Configurator\NormalizerBuilderConfigurator;

final class DomainObjectConfigurator implements NormalizerBuilderConfigurator
{
    public function configureNormalizerBuilder(NormalizerBuilder $builder): NormalizerBuilder
    {
        return $builder
            ->registerTransformer(
                fn (\DateTimeInterface $date) => $date->format('Y-m-d')
            )
            ->registerTransformer(
                fn (\My\App\Money $money) => [
                    'amount' => $money->amount,
                    'currency' => $money->currency->value,
                ]
            );
    }
}

final class SensitiveDataConfigurator implements NormalizerBuilderConfigurator
{
    public function configureNormalizerBuilder(NormalizerBuilder $builder): NormalizerBuilder
    {
        return $builder
            ->registerTransformer(
                fn (\My\App\EmailAddress $email) => '***@' . $email->domain()
            );
    }
}

$json = (new \CuyZ\Valinor\NormalizerBuilder())
    ->configureWith(
        new \My\App\DomainObjectConfigurator(),
        new \My\App\SensitiveDataConfigurator(),
    )
    ->normalizer(\CuyZ\Valinor\Normalizer\Format::json())
    ->normalize($someObject);

CamelCase/snake_case keys conversion support

Two configurators are available to convert the keys of input data before mapping them to object properties or shaped array keys. This allows accepting data with a different naming convention than the one used in the PHP codebase.

ConvertKeysToCamelCase
Conversion
first_namefirstName
FirstNamefirstName
first-namefirstName
$user = (new \CuyZ\Valinor\MapperBuilder())
    ->configureWith(
        new \CuyZ\Valinor\Mapper\Configurator\ConvertKeysToCamelCase()
    )
    ->mapper()
    ->map(\My\App\User::class, [
        'first_name' => 'John', // mapped to `$firstName`
        'last_name' => 'Doe',   // mapped to `$lastName`
    ]);
ConvertKeysToSnakeCase
Conversion
firstNamefirst_name
FirstNamefirst_name
first-namefirst_name
$user = (new \CuyZ\Valinor\MapperBuilder())
    ->configureWith(
        new \CuyZ\Valinor\Mapper\Configurator\ConvertKeysToSnakeCase()
    )
    ->mapper()
    ->map(\My\App\User::class, [
        'firstName' => 'John', // mapped to `$first_name`
        'lastName' => 'Doe',   // mapped to `$last_name`
    ]);

This configurator can be combined with a key restriction configurator to both validate and convert keys in a single step. The restriction configurator must be registered before the conversion so that the validation runs on the original input keys.

use CuyZ\Valinor\MapperBuilder;
use CuyZ\Valinor\Mapper\Configurator\ConvertKeysToCamelCase;
use CuyZ\Valinor\Mapper\Configurator\RestrictKeysToSnakeCase;

$user = (new MapperBuilder())
    ->configureWith(
        new RestrictKeysToSnakeCase(),
        new ConvertKeysToCamelCase(),
    )
    ->mapper()
    ->map(User::class, [
        'first_name' => 'John',
        'last_name' => 'Doe',
    ]);

Keys case restriction support

Four configurators restrict which key case is accepted when mapping input data to objects or shaped arrays. If a key does not match the expected case, a mapping error will be raised.

This is useful, for instance, to enforce a consistent naming convention across an API's input to ensure that a JSON payload only contains camelCase, snake_case, PascalCase or kebab-case keys.

Available configurators:

Configurator Example
new RestrictKeysToCamelCase() firstName
new RestrictKeysToPascalCase() FirstName
new RestrictKeysToSnakeCase() first_name
new RestrictKeysToKebabCase() first-name
$user = (new \CuyZ\Valinor\MapperBuilder())
    ->configureWith(
        new \CuyZ\Valinor\Mapper\Configurator\RestrictKeysToCamelCase()
    )
    ->mapper()
    ->map(\My\App\User::class, [
        'firstName' => 'John', // Ok
        'last_name' => 'Doe',  // Error
    ]);
Features
  • Add HTTP request mapping support (385f0c)
  • Add configurator support for mapper and normalizer builders (49dd0a)
  • Add mapper configurators to convert keys to camelCase/snake_case (a92bd3)
  • Add mapper configurators to restrict keys cases (0be7dc)
  • Introduce key converters to transform source keys (bfd4ab)
Bug Fixes
  • Allow mapping a single value to a list type (7241a6)
  • Disallow duplicate converted keys (498dcf)
  • Handle concurrent cache directory race condition (13f06d)
  • Properly invalidate cache entries when using FileWatchingCache (d445e4)
Internal
Deps
  • Update dependencies (73a1cb)
  • Update mkdocs dependencies (67a6b4)

v2.3.2

Compare Source

Notable changes

End of PHP 8.1 support

PHP 8.1 security support has ended on the 31st of December 2025.

See: https://www.php.net/supported-versions.php

Removal of composer-runtime-api package dependency

Using the composer-runtime-api library leads to unnecessary IO everytime the library is used; therefore, we prefer to use a basic constant that contains the package version.

This change slightly increases performance and makes the package completely dependency free. 🎉

Bug Fixes
  • Properly handle attribute transformers compilation (747414)
  • Properly handle imported function's namespace resolution (7757bd)
  • Properly handle large string integer casting (b4d9a4)
  • Simplify circular dependency handling (a7d8e2)
  • Use native type if advanced type unresolvable in normalizer compile (121798)
Cache
  • Only unlink temp file if still exists (58b89c)
Internal
  • Remove unused exception (aad781)
  • Replace composer-runtime-api requirement by PHP constant usage (8152be)
  • Standardize documentation comments (274207)
  • Use internal interface for mapping logical exception (8e00d3)
Other
  • Drop support for PHP 8.1 (fec22a)
  • Separate unexpected mapped keys in own errors (332ef6)

v2.3.1

Compare Source

Bug Fixes
  • Handle default value retrieval for properties (45b9de)

v2.3.0

Compare Source

Notable new features

PHP 8.5 support 🐘

Enjoy the upcoming PHP 8.5 version before it is even officially released!

Performance improvements

The awesome @​staabm has identified some performance bottlenecks in the codebase, leading to changes that improved the execution time of the mapper by ~50% in his case (and probably some of yours)!

Incoming HTTP request mapping

There is an ongoing discussion to add support for HTTP request mapping, if that's something you're interested in, please join the discussion!

Features
  • Add support for closures in attributes (d25d6f)
  • Add support for PHP 8.5 (7c34e7)
Other
  • Support empty shaped array (a3eec8)
Internal
  • Change compiled transformer method hashing algo (cf112b)
  • Micro-optimize arguments conversion to shaped array (33346d)
  • Use memoization for ShapedArrayType::toString() (4fcfb6)
  • Use memoization for arguments' conversion to shaped array (0f83be)
  • Use memoization for type dumping (f47613)

v2.2.2

Compare Source

Bug Fixes
  • Handle object arguments default value (c2cee2)

v2.2.1

Compare Source

⚠️ Important changes ⚠️

This release contains a lot of internal refactorings that were needed to fix an important bug regarding converters. Although we made our best to provide a stable release, bugs can have slipped through the cracks. If that's the case, please open an issue describing the issue and we will try to fix it as soon as possible.

⚠️ This fix is not backward-compatible in some cases, which are explained below. If you use mapper converters in your application, you should definitely read the following changes carefully.


The commit [d9e3cf0] is the result of a long journey whose goal was to fix a very upsetting bug that would make mapper converters being called when they shouldn't be. This could result in unexpected behaviors and could even lead to invalid data being mapped.

Take the following example below:

We register a converter that will return null if the string length is lower than 5. For this converter to be called, the target type should match the string|null type, because that is what the converter can return.

In this example, we want to map a value to string, which is not matched by the converter return type because it does not contain null. This means that the converter should never be called, because it could return an invalid value (null will never be a valid string).

 (new \CuyZ\Valinor\MapperBuilder())
    ->registerConverter(
        // If the string length is lower than 5, we return `null`
        fn (string $val): ?string => strlen($val) < 5 ? null : $val
    )
    ->mapper()
    ->map('string', 'foo');

Before this commit, the converter would be called and return null, which would raise an unexpected error:

An error occurred at path root: value null is not a valid string.

This error was caused by the following line:

if (! $shell->type->matches($converter->returnType)) {
    continue;
}

It should have been:

if (! $converter->returnType->matches($shell->type)) {
    continue;
}

Easy fix, isn't it?

Well… actually no. Because changing this completely modifies the behavior of the converters, and the library is now missing a lot of information to properly infer the return type of the converter.

In some cases this change was enough, but in some more complex cases we now would need more information.

For instance, let's take the CamelCaseKeys example as it was written in the documentation before this commit:

final class CamelCaseKeys
{
    /**
     * @param array<mixed> $value
     * @param callable(array<mixed>): object $next
     */
    public function map(array $value, callable $next): object { … }
}

There is a big issue in the types signature of this converter: the object return type means that the converter can return anything, as long as this is an object. This breaks the type matching contract and the converter should never be called. But it was.

This is the new way of writing this converter:

final class CamelCaseKeys
{
    /**
     * @template T of object
     * @param array<mixed> $value
     * @param callable(array<mixed>): T $next
     * @return T
     */
    public function map(array $value, callable $next): object { … }

Now, the type matching contract is respected because of the @template annotation, and the converter is called when mapping to any object.

To be able to properly infer the return type of the converter, we needed to:

  1. Be able to understand @template annotations inside functions
  2. Be able to statically infer the generics using these annotations
  3. Assign the inferred generics to the whole converter
  4. Let the system call the converter pipeline properly

This was a huge amount of work, which required several small changes during the last month, as well as [b7f3e5f] and [d9e3cf0]. A lot of work for an error in a single line of code, right? T_T

The good news is: the library is now more powerful than ever, as it is now able to statically infer generic types, which could bring new possibilities in the future.

Now the bad news is: this commit can break backwards compatibility promise in some cases. But as this is still a (huge) bug fix, we will not release a new major version, although it can break some existing code. Instead, converters should be adapted to use proper type signatures.

To help with that, here are the list of the diff that should be applied to converter examples that were written in the documentation:

CamelCaseKeys

#[\CuyZ\Valinor\Mapper\AsConverter]
#[\Attribute(\Attribute::TARGET_CLASS)]
final class CamelCaseKeys
{
    /**
+    * @template T of object
     * @param array<mixed> $value
-    * @param callable(array<mixed>): object $next
+    * @param callable(array<mixed>): T $next
+    * @return T
     */
    public function map(array $value, callable $next): object
    {
        …
    }
}

RenameKeys

#[\CuyZ\Valinor\Mapper\AsConverter]
#[\Attribute(\Attribute::TARGET_CLASS)]
final class RenameKeys
{
    public function __construct(
        /** @var non-empty-array<non-empty-string, non-empty-string> */
        private array $mapping,
    ) {}

    /**
+    * @template T of object
     * @param array<mixed> $value
-    * @param callable(array<mixed>): object $next
+    * @param callable(array<mixed>): T $next
+    * @return T
     */
    public function map(array $value, callable $next): object
    {
        …
    }
}

Explode

#[\CuyZ\Valinor\Mapper\AsConverter]
#[\Attribute(\Attribute::TARGET_PROPERTY)]
final class Explode
{
    public function __construct(
        /** @var non-empty-string */
        private string $separator,
    ) {}

    /**
-    * @return array<mixed>
+    * @return list<string>
     */
    public function map(string $value): array
    {
        return explode($this->separator, $value);
    }
}

ArrayToList

#[\CuyZ\Valinor\Mapper\AsConverter]
#[\Attribute(\Attribute::TARGET_PROPERTY)]
final class ArrayToList
{
    /**
     * @template T
-    * @param array<mixed> $value
+    * @param non-empty-array<T> $value
-    * @return list<mixed>
+    * @return non-empty-list<T>
     */
    public function map(array $value): array
    {
        return array_values($value);
    }
}

JsonDecode

#[\CuyZ\Valinor\Mapper\AsConverter]
#[\Attribute(\Attribute::TARGET_PROPERTY)]
final class JsonDecode
{
     /**
+    * @template T
-    * @param callable(mixed): mixed $next
+    * @param callable(mixed): T $next
+    * @return T
     */
    public function map(string $value, callable $next): mixed
    {
        $decoded = json_decode($value, associative: true);

        return $next($decoded);
    }
}
Bug Fixes
  • Make iterable type not match array types (27f2e3)
  • Prevent undefined object type to match invalid types (4ae98a)
  • Properly handle union and array-key types matching (71787a)
  • Use converter only if its return type matches the current node (d9e3cf)
Internal
  • Detect converter argument value using native functions (81b4e5)
  • Refactor class and interface mapping process (ab1350)
  • Refactor definition type assignments to handle generic types (b7f3e5)
  • Refactor shell responsibilities and node builders API (63624c)
  • Remove exception code timestamps from codebase (460bb2)
  • Use INF constant to detect default converter value (72079b)
Other
  • Enhance `cal

Note

PR body was truncated to here.


Configuration

📅 Schedule: (in timezone UTC)

  • Branch creation
    • At any time (no schedule defined)
  • Automerge
    • At any time (no schedule defined)

🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.

Rebasing: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 Ignore: Close this PR and you won't be reminded about this update again.


  • If you want to rebase/retry this PR, check this box

This PR was generated by Mend Renovate. View the repository job log.

@renovate renovate Bot added the renovate label Nov 25, 2025
@renovate
renovate Bot force-pushed the renovate/cuyz-valinor-2.x branch 7 times, most recently from cfd5457 to 23ffe46 Compare November 30, 2025 13:09
@renovate
renovate Bot force-pushed the renovate/cuyz-valinor-2.x branch 6 times, most recently from 049200b to e53d51a Compare December 12, 2025 00:04
@renovate
renovate Bot force-pushed the renovate/cuyz-valinor-2.x branch 3 times, most recently from 8e42656 to 2c5cf92 Compare December 20, 2025 08:43
@renovate
renovate Bot force-pushed the renovate/cuyz-valinor-2.x branch from 2c5cf92 to 9b7c208 Compare December 31, 2025 15:58
@renovate
renovate Bot force-pushed the renovate/cuyz-valinor-2.x branch 3 times, most recently from f95a62e to 6ecd49e Compare January 19, 2026 21:16
@renovate
renovate Bot force-pushed the renovate/cuyz-valinor-2.x branch 7 times, most recently from 2f9c09f to ac5dea8 Compare January 27, 2026 09:00
@renovate
renovate Bot force-pushed the renovate/cuyz-valinor-2.x branch 2 times, most recently from 798d51d to 453ef0c Compare January 30, 2026 22:07
@renovate
renovate Bot force-pushed the renovate/cuyz-valinor-2.x branch 2 times, most recently from 12f4be5 to 67957b8 Compare March 3, 2026 00:39
@renovate
renovate Bot force-pushed the renovate/cuyz-valinor-2.x branch 2 times, most recently from d4a4c2e to 01135c8 Compare March 6, 2026 18:19
@renovate
renovate Bot force-pushed the renovate/cuyz-valinor-2.x branch 5 times, most recently from e4d9fc4 to 95c7fed Compare March 23, 2026 17:44
@renovate
renovate Bot force-pushed the renovate/cuyz-valinor-2.x branch 6 times, most recently from f05538e to 498eea7 Compare March 30, 2026 14:33
@renovate
renovate Bot force-pushed the renovate/cuyz-valinor-2.x branch 3 times, most recently from 3b91f98 to d870dc3 Compare April 8, 2026 00:55
@renovate
renovate Bot force-pushed the renovate/cuyz-valinor-2.x branch 7 times, most recently from 45a1703 to 107b0d8 Compare April 16, 2026 22:39
@renovate
renovate Bot force-pushed the renovate/cuyz-valinor-2.x branch 2 times, most recently from 7c04400 to 9f35972 Compare April 21, 2026 23:35
@renovate
renovate Bot force-pushed the renovate/cuyz-valinor-2.x branch 2 times, most recently from 1c29c9a to c319c22 Compare April 26, 2026 16:43
| datasource | package      | from   | to    |
| ---------- | ------------ | ------ | ----- |
| packagist  | cuyz/valinor | 1.17.0 | 2.6.0 |
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants