Skip to content

Releases: CuyZ/Valinor

2.4.0

23 Mar 17:40

Choose a tag to compare

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 n...

Read more

2.3.2

23 Jan 15:26

Choose a tag to compare

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)

2.3.1

21 Oct 12:17

Choose a tag to compare

Bug Fixes

  • Handle default value retrieval for properties (45b9de)

2.3.0

21 Oct 12:09

Choose a tag to compare

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)

2.2.2

13 Oct 07:23

Choose a tag to compare

Bug Fixes

  • Handle object arguments default value (c2cee2)

2.2.1

12 Oct 22:36

Choose a tag to compare

⚠️ 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 callable type parsing (2563a3)

2.2.0

29 Sep 13:07

Choose a tag to compare

Notable new features

Mapping error messages improvements

Feedback has been improved in mapping error messages, especially the expected signature of the failing nodes.

This gets rid of the infamous ? that was used whenever an object was present in a type, leading to incomplete and misleading messages.

Example of a new message:

final class User
{
    public function __construct(
        public string $name,
        public int $age,
    ) {}
}

(new MapperBuilder())
    ->mapper()
    ->map(User::class, 'invalid value');

// Could not map type `User`. An error occurred at path *root*: Value
// 'invalid value' does not match `array{name: string, age: int}`.

Features

  • Improve mapping error messages types signatures (ce1b0a)

Bug Fixes

  • Prevent undefined values in non-empty-list (9739cd)
  • Properly detect nested invalid types during mapping (ad756a)
  • Use proper error message for invalid nullable scalar value (b84cbe)

Other

  • Add safeguard in type parsing when reading next type (da0de0)
  • Improve type parsing error when an unexpected token is found (5ae904)
  • Lighten types initialization (6f0b3f)
  • Parse iterable type the same way it is done with array (6291a7)
  • Rework how type traversing is used (20f17f)
  • Set default exception error code to unknown (c8ef49)

2.1.2

28 Aug 12:42

Choose a tag to compare

Bug Fixes

  • Prevent converters from being called several times on same node (15be9e)

Other

  • Add missing @pure annotations (c3871f)
  • Exclude unneeded methods when building class definition (0cf9f8)

2.1.1

23 Jul 20:32

Choose a tag to compare

Bug Fixes

  • Handle errors priorities when mapping to a union type (42cd02)
  • Properly flatten node path when single value objects are used (5b0bf2)

2.1.0

23 Jul 11:48

Choose a tag to compare

Notable changes

Attribute converters

Note

Fetch common examples of mapping converters in the documentation.

Callable converters allow targeting any value during mapping, whereas attribute converters allow targeting a specific class or property for a more granular control.

To be detected by the mapper, an attribute class must be registered first by adding the AsConverter attribute to it.

Attributes must declare a method named map that follows the same rules as callable converters: a mandatory first parameter and an optional second callable parameter.

Below is an example of an attribute converter that converts string inputs to boolean values based on specific string inputs:

namespace My\App;

#[\CuyZ\Valinor\Mapper\AsConverter]
#[\Attribute(\Attribute::TARGET_PROPERTY)]
final class CastToBool
{
    /**
     * @param callable(mixed): bool $next
     */
    public function map(string $value, callable $next): bool
    {
        $value = match ($value) {
            'yes', 'on' => true,
            'no', 'off' => false,
            default => $value,
        };
        
        return $next($value);
    }
}

final class User
{
    public string $name;
    
    #[\My\App\CastToBool]
    public bool $isActive;
}

$user = (new \CuyZ\Valinor\MapperBuilder())
    ->mapper()
    ->map(User::class, [
        'name' => 'John Doe',
        'isActive' => 'yes',
    ]);

$user->name === 'John Doe';
$user->isActive === true;

Attribute converters can also be used on function parameters when mapping arguments:

function someFunction(string $name, #[\My\App\CastToBool] bool $isActive) {
    // …
};

$arguments = (new \CuyZ\Valinor\MapperBuilder())
    ->argumentsMapper()
    ->mapArguments(someFunction(...), [
        'name' => 'John Doe',
        'isActive' => 'yes',
    ]);

$arguments['name'] === 'John Doe';
$arguments['isActive'] === true;

When there is no control over the converter attribute class, it is possible to register it using the registerConverter method.

(new \CuyZ\Valinor\MapperBuilder())
    ->registerConverter(\Some\External\ConverterAttribute::class)
    ->mapper()
    ->map(…);

It is also possible to register attributes that share a common interface by giving the interface name to the registration method.

namespace My\App;

interface SomeAttributeInterface {}

#[\Attribute]
final class SomeAttribute implements \My\App\SomeAttributeInterface {}

#[\Attribute]
final class SomeOtherAttribute implements \My\App\SomeAttributeInterface {}

(new \CuyZ\Valinor\MapperBuilder())
    // Registers both `SomeAttribute` and `SomeOtherAttribute` attributes
    ->registerConverter(\My\App\SomeAttributeInterface::class)
    ->mapper()
    ->map(…);

Features

  • Introduce attribute converters for granular control during mapping (0a8c0d)

Bug Fixes

  • Properly detect invalid values returned by mapping converters (e80de7)
  • Properly extract = token when reading types (9a511d)
  • Use polyfill for array_find (540741)

Other

  • Mark exception as @internal (f3eace)