Weave Code
Code Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Valinor Laravel Package

cuyz/valinor

Valinor maps raw input (JSON/arrays) into fully typed PHP objects, validating along the way with clear, human-readable errors. Supports advanced types (PHPStan/Psalm), shaped arrays, generics, ranges, and can normalize objects back to JSON/CSV while preserving structure.

View on GitHub
Deep Wiki
Context7
2.4.0

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](https://github.com/param) non-empty-string $page
     * [@param](https://github.com/param) positive-int $page
     * [@param](https://github.com/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](https://github.com/param) non-empty-string $author
     * [@param](https://github.com/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](https://github.com/var) non-empty-string */
        public string $status,
        /** [@var](https://github.com/var) positive-int */
        public int $page = 1,
        /** [@var](https://github.com/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](https://github.com/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](https://github.com/var) non-empty-string */
        #[FromBody] public string $author,
        /** [@var](https://github.com/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)
2.3.2

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

Bug Fixes

  • Handle default value retrieval for properties (45b9de)
2.3.0

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

Bug Fixes

  • Handle object arguments default value (c2cee2)
2.2.1

⚠️ 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](https://github.com/param) array<mixed> $value
     * [@param](https://github.com/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](https://github.com/template) T of object
     * [@param](https://github.com/param) array<mixed> $value
     * [@param](https://github.com/param) callable(array<mixed>): T $next
     * [@return](https://github.com/return) T
     */
    public function map(array $value, callable $next): object { … }

Now, the type matching contract is respected because of the [@template](https://github.com/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](https://github.com/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](https://github.com/template) T of object
     * [@param](https://github.com/param) array<mixed> $value
-    * [@param](https://github.com/param) callable(array<mixed>): object $next
+    * [@param](https://github.com/param) callable(array<mixed>): T $next
+    * [@return](https://github.com/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](https://github.com/var) non-empty-array<non-empty-string, non-empty-string> */
        private array $mapping,
    ) {}

    /**
+    * [@template](https://github.com/template) T of object
     * [@param](https://github.com/param) array<mixed> $value
-    * [@param](https://github.com/param) callable(array<mixed>): object $next
+    * [@param](https://github.com/param) callable(array<mixed>): T $next
+    * [@return](https://github.com/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](https://github.com/var) non-empty-string */
        private string $separator,
    ) {}

    /**
-    * [@return](https://github.com/return) array<mixed>
+    * [@return](https://github.com/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](https://github.com/template) T
-    * [@param](https://github.com/param) array<mixed> $value
+    * [@param](https://github.com/param) non-empty-array<T> $value
-    * [@return](https://github.com/return) list<mixed>
+    * [@return](https://github.com/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](https://github.com/template) T
-    * [@param](https://github.com/param) callable(mixed): mixed $next
+    * [@param](https://github.com/param) callable(mixed): T $next
+    * [@return](https://github.com/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

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

Bug Fixes

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

Other

  • Add missing [@pure](https://github.com/pure) annotations (c3871f)
  • Exclude unneeded methods when building class definition (0cf9f8)
2.1.1

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

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](https://github.com/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](https://github.com/internal) (f3eace)
2.0.0

First release of the v2 series! 🎉

This release introduces some new features but also backward compatibility breaks that are detailed in the upgrading chapter: it is strongly recommended to read it carefully before upgrading.

Notable new features

Mapper converters introduction

A mapper converter allows users to hook into the mapping process and apply custom logic to the input, by defining a callable signature that properly describes when it should be called:

  • A first argument with a type matching the expected input being mapped
  • A return type representing the targeted mapped type

These two types are enough for the library to know when to call the converter and can contain advanced type annotations for more specific use cases.

Below is a basic example of a converter that converts string inputs to uppercase:

(new \CuyZ\Valinor\MapperBuilder())
    ->registerConverter(
        fn (string $value): string => strtoupper($value)
    )
    ->mapper()
    ->map('string', 'hello world'); // 'HELLO WORLD'

Converters can be chained, allowing multiple transformations to be applied to a value. A second callable parameter can be declared, allowing the current converter to call the next one in the chain.

A priority can be given to a converter to control the order in which converters are applied. The higher the priority, the earlier the converter will be executed. The default priority is 0.

(new \CuyZ\Valinor\MapperBuilder())
    ->registerConverter(
        function(string $value, callable $next): string {
            return $next(strtoupper($value));
        }
    )
    ->registerConverter(
        function(string $value, callable $next): string {
            return $next($value . '!');
        },
        priority: -10,
    )
    ->registerConverter(
        function(string $value, callable $next): string {
            return $next($value . '?');
        },
        priority: 10,
    )
    ->mapper()
    ->map('string', 'hello world'); // 'HELLO WORLD?!'

More information can be found in the mapper converter chapter.

NormalizerBuilder introduction

The NormalizerBuilder class has been introduced and will now be the main entry to instantiate normalizers. Therefore, the methods inMapperBuilder that used to configure and return normalizers have been removed.

This decision aims to make a clear distinction between the mapper and the normalizer configuration API, where confusion could arise when using both.

The NormalizerBuilder can be used like this:

$normalizer = (new \CuyZ\Valinor\NormalizerBuilder())
    ->registerTransformer(
        fn (\DateTimeInterface $date) => $date->format('Y/m/d')
    )
    ->normalizer(\CuyZ\Valinor\Normalizer\Format::array())
    ->normalize($someData);

Changes to messages/errors handling

Some changes have been made to the way messages and errors are handled.

It is now easier to fetch messages when error(s) occur during mapping:

try {
    (new \CuyZ\Valinor\MapperBuilder())->mapper()->map(/* … */);
} catch (\CuyZ\Valinor\Mapper\MappingError $error) {
    // Before (1.x):
    $messages = \CuyZ\Valinor\Mapper\Tree\Message\Messages::flattenFromNode(
        $error->node()
    );
    
    // After (2.x):
    $messages = $error->messages();
}

Upgrading from 1.x to 2.x

See the upgrading chapter.

⚠ BREAKING CHANGES

  • Add purity markers in MapperBuilder and NormalizerBuilder (123058)
  • Add type and source accessors to MappingError (378141)
  • Change exposed error messages codes (15bb11)
  • Introduce NormalizerBuilder as the main entry for normalizers (f79ce2)
  • Introduce internal cache interface and remove PSR-16 dependency (dfdf40)
  • Mark some class constructors as [@internal](https://github.com/internal) (7fe5fe)
  • Remove MapperBuilder::alter() in favor of mapper converters (bee098)
  • Remove MapperBuilder::enableFlexibleCasting() (f8f16d)
  • Remove unused class PrioritizedList (0b8c89)
  • Remove unused interface IdentifiableSource (aefb20)
  • Rename MapperBuilder::warmup() method to warmupCacheFor() (963156)
  • Rework mapper node and messages handling (14d5ca)

Features

  • Allow MapperBuilder and NormalizerBuilder to clear cache (fe318c)
  • Introduce mapper converters to apply custom logic during mapping (46c823)

Bug Fixes

  • Update file system cache entries permissions (6ffb0f)

Other

  • Remove Throwable inheritance from ErrorMessage (dbd731)
  • Remove old class doc block (4c2194)
  • Remove unused property (53841a)
1.17.0

Notable changes

Flexible casting setting split

The mapper setting enableFlexibleCasting is (softly) deprecated in favor of three distinct modes, which guarantee the same functionalities as before.

Allowing scalar value casting:

With this setting enabled, scalar types will accept castable values:

  • Integer types will accept any valid numeric value, for instance the string value "42".

  • Float types will accept any valid numeric value, for instance the string value "1337.42".

  • String types will accept any integer, float or object implementing the Stringable interface.

  • Boolean types will accept any truthy or falsy value:

    • (string) "true", (string) "1" and (int) 1 will be cast to true
    • (string) "false", (string) "0" and (int) 0 will be cast to false
(new \CuyZ\Valinor\MapperBuilder())
    ->allowScalarValueCasting()
    ->mapper()
    ->map('array{id: string, price: float, active: bool}', [
        'id' => 549465210, // Will be cast to string
        'price' => '42.39', // Will be cast to float
        'active' => 1, // Will be cast to bool
    ]);

Allowing non-sequential lists:

By default, list types will only accept sequential keys starting from 0.

This setting allows the mapper to convert associative arrays to a list with sequential keys.

(new \CuyZ\Valinor\MapperBuilder())
    ->allowNonSequentialList()
    ->mapper()
    ->map('list<int>', [
        'foo' => 42,
        'bar' => 1337,
    ]);

// => [0 => 42, 1 => 1337]

Allowing undefined values:

Allows the mapper to accept undefined values (missing from the input), by converting them to null (if the current type is nullable) or an empty array (if the current type is an object or an iterable).

(new \CuyZ\Valinor\MapperBuilder())
    ->allowUndefinedValues()
    ->mapper()
    ->map('array{name: string, age: int|null}', [
        'name' => 'John Doe',
        // 'age' is not defined
    ]);

// => ['name' => 'John Doe', 'age' => null]

Features

  • Split flexible casting setting in three distinct modes (02ef8e)

Other

  • Simplify ValueNode implementation (7e6ccf)
1.16.1

Bug Fixes

  • Handle mapping of argument of type object with names shared (771696)
1.16.0

Features

  • Add support for scalar type (eaebe1)
  • Handle flattened values when mapping to a single object argument (6ca0ee)
1.15.0

Notable changes

Normalizer compilation

A new compilation step for the normalizer has been implemented, which aims to bring huge performance gains when normalizing any value. It works by adding a static analysis pass to the process, which will recursively analyse how the normalizer should perform for every value it can meet. This process results in a native PHP code entry, that can then be cached for further usage.

This compilation cache feature is automatically enabled when adding the cache in the mapper builder. This should be transparent for most users, but as this is a major change in the code (see Pull Request #500), some bugs may have slipped through. If you encounter such issues that look related to this change, please open an issue and we will try to fix it as soon as possible.

[!NOTE] My goal remains to provide users of this library with the best possible experience. To that end, motivational messages and financial support are greatly appreciated. If you use this library and find it useful, please consider sponsoring the project on GitHub 🤗

The development of this feature took nearly two years, mainly due to limited spare time to work on it. I hope you enjoy this feature as much as I enjoyed building it!

On a side note, the next major project will be adding a compiled cache entry feature for mappers, similar to how it was implemented for normalizers. Stay tuned…

Features

  • Introduce compiled normalizer cache (a4b2a7)

Bug Fixes

  • Accept an object implementing an interface without infer setting (edd488)
  • Handle self-referential types in object constructors (dc7b6a)
  • Properly handle interface with no implementation in union type (f3f98d)
  • Properly match class-string type with no subtype (c8fe90)

Other

  • Add methods to fetch native types (7213eb)
  • Improve integer value type match algorithm (048a48)
  • Update default error message for invalid value for union type (d1ab6a)
1.14.4

Bug Fixes

  • Properly handle superfluous keys when source is an iterable (33ec7e)
1.14.3

Bug Fixes

  • Normalize empty iterable object as empty array in JSON (a22a53)
  • Properly handle full namespaced enum type in docblock (eb8816)
  • Support PHPStan extension for PHPStan v1 and v2 (9f043b)

Other

  • Handle trailing comma in shaped array declaration (1cb4e9)
  • Make sure a child shell is not root (ef0b5c)
  • Refactor node builders stack (040b90)
  • Remove ErrorCatcherNodeBuilder (f8eedc)
  • Remove IterableNodeBuilder (339f10)
  • Remove the need to keep a reference to the Shell parent node (070db3)
1.14.2

Bug Fixes

  • Properly handle array arguments during attributes compilation (abfca1)

Other

  • Fix PHP 8.4 deprecations (b698de)
  • Remove "pure" requirements from several methods (b15d1a)
  • Update dependencies (564691)
  • Update PHPStan to version 2 (9ef3cf)
1.14.1

Bug Fixes

  • Properly handle partial namespace signature (9b58f2)
1.14.0

Notable changes

PHP 8.4 support 🐘

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

Pretty JSON output

The JSON_PRETTY_PRINT option is now supported by the JSON normalizer and will format the ouput with whitespaces and line breaks:

$input = [
    'value' => 'foo',
    'list' => [
        'foo',
        42,
        ['sub']
    ],
    'associative' => [
        'value' => 'foo',
        'sub' => [
            'string' => 'foo',
            'integer' => 42,
        ],
    ],
];

(new \CuyZ\Valinor\MapperBuilder())
    ->normalizer(\CuyZ\Valinor\Normalizer\Format::json())
    ->withOptions(\JSON_PRETTY_PRINT)
    ->normalize($input);

// Result:
// {
//     "value": "foo",
//     "list": [
//         "foo",
//         42,
//         [
//             "sub"
//         ]
//     ],
//     "associative": {
//         "value": "foo",
//         "sub": {
//             "string": "foo",
//             "integer": 42
//         }
//     }
// }

Force array as object in JSON output

The JSON_FORCE_OBJECT option is now supported by the JSON normalizer and will force the output of an array to be an object:

(new \CuyZ\Valinor\MapperBuilder())
    ->normalizer(Format::json())
    ->withOptions(JSON_FORCE_OBJECT)
    ->normalize(['foo', 'bar']);

// {"0":"foo","1":"bar"}

Features

  • Add support for JSON_FORCE_OBJECT option in JSON normalizer (f3e8c1)
  • Add support for PHP 8.4 (07a06a)
  • Handle JSON_PRETTY_PRINT option with the JSON normalizer (950395)

Bug Fixes

  • Handle float type casting properly (8742b2)
  • Handle namespace for Closure without class scope (7a0fc2)
  • Prevent cache corruption when normalizing and mapping to enum (e695b2)
  • Properly handle class sharing class name and namespace group name (6e68d6)

Other

  • Change implicitly nullable parameter types (304db3)
  • Fix typo in property type annotation (b9c6ad)
  • Use xxh128 hash algorithm for cache keys (546c45)
1.13.0

Notable changes

Microseconds support for timestamp format

Prior to this patch, this would require a custom constructor in the form of:

static fn(float | int $timestamp): DateTimeImmutable => new
    DateTimeImmutable(sprintf("@%d", $timestamp)),

This bypasses the datetime format support of Valinor entirely. This is required because the library does not support floats as valid DateTimeInterface input values.

This commit adds support for floats and registers timestamp.microseconds (U.u) as a valid default format.

Support for value-of<BackedEnum> type

This type can be used as follows:

enum Suit: string
{
    case Hearts = 'H';
    case Diamonds = 'D';
    case Clubs = 'C';
    case Spades = 'S';
}

$suit = (new \CuyZ\Valinor\MapperBuilder())
    ->mapper()
    ->map('value-of<Suit>', 'D');

// $suit === 'D'

Object constructors parameters types inferring improvements

The collision system that checks object constructors parameters types is now way more clever, as it no longer checks for parameters' names only. Types are now also checked, and only true collision will be detected, for instance when two constructors share a parameter with the same name and type.

Note that when two parameters share the same name, the following type priority operates:

  1. Non-scalar type
  2. Integer type
  3. Float type
  4. String type
  5. Boolean type

With this change, the code below is now valid:

final readonly class Money
{
    private function __construct(
        public int $value,
    ) {}

    #[\CuyZ\Valinor\Mapper\Object\Constructor]
    public static function fromInt(int $value): self
    {
        return new self($value);
    }

    #[\CuyZ\Valinor\Mapper\Object\Constructor]
    public static function fromString(string $value): self
    {
        if (! preg_match('/^\d+€$/', $value)) {
            throw new \InvalidArgumentException('Invalid money format');
        }

        return new self((int)rtrim($value, '€'));
    }
}

$mapper = (new \CuyZ\Valinor\MapperBuilder())->mapper();

$mapper->map(Money::class, 42); // ✅
$mapper->map(Money::class, '42€'); // ✅

Features

  • Add microseconds support to timestamp format (02bd2e)
  • Add support for value-of<BackedEnum> type (b1017c)
  • Improve object constructors parameters types inferring (2150dc)

Bug Fixes

  • Allow any constant in class constant type (694275)
  • Allow docblock for transformer callable type (69e0e3)
  • Do not override invalid variadic parameter type (c5860f)
  • Handle interface generics (40e6fa)
  • Handle iterable objects as iterable during normalization (436e3c)
  • Properly format empty object with JSON normalizer (ba22b5)
  • Properly handle nested local type aliases (127839)

Other

  • Exclude unneeded attributes in class/function definitions (1803d0)
  • Improve mapping performance for nullable union type (6fad94)
  • Move "float type accepting integer value" logic in Shell (047953)
  • Move setting values in shell (84b1ff)
  • Reorganize type resolver services (86fb7b)
1.12.0

Notable changes

Introduce unsealed shaped array syntax

This syntax enables an extension of the shaped array type by allowing additional values that must respect a certain type.

$mapper = (new \CuyZ\Valinor\MapperBuilder())->mapper();

// Default syntax can be used like this:
$mapper->map(
    'array{foo: string, ...array<string>}',
    [
        'foo' => 'foo',
        'bar' => 'bar', // ✅ valid additional value
    ]
);

$mapper->map(
    'array{foo: string, ...array<string>}',
    [
        'foo' => 'foo',
        'bar' => 1337, // ❌ invalid value 1337
    ]
);

// Key type can be added as well:
$mapper->map(
    'array{foo: string, ...array<int, string>}',
    [
        'foo' => 'foo',
        42 => 'bar', // ✅ valid additional key
    ]
);

$mapper->map(
    'array{foo: string, ...array<int, string>}',
    [
        'foo' => 'foo',
        'bar' => 'bar' // ❌ invalid key
    ]
);

// Advanced types can be used:
$mapper->map(
    "array{
        'en_US': non-empty-string,
        ...array<non-empty-string, non-empty-string>
    }",
    [
        'en_US' => 'Hello',
        'fr_FR' => 'Salut', // ✅ valid additional value
    ]
);

$mapper->map(
    "array{
        'en_US': non-empty-string,
        ...array<non-empty-string, non-empty-string>
    }",
    [
        'en_US' => 'Hello',
        'fr_FR' => '', // ❌ invalid value
    ]
);

// If the permissive type is enabled, the following will work:
(new \CuyZ\Valinor\MapperBuilder())
    ->allowPermissiveTypes()
    ->mapper()
    ->map(
        'array{foo: string, ...}',
        ['foo' => 'foo', 'bar' => 'bar', 42 => 1337]
    ); // ✅

Interface constructor registration

By default, the mapper cannot instantiate an interface, as it does not know which implementation to use. To do so, the MapperBuilder::infer() method can be used, but it is cumbersome in most cases.

It is now also possible to register a constructor for an interface, in the same way as for a class.

Because the mapper cannot automatically guess which implementation can be used for an interface, it is not possible to use the Constructor attribute, the MapperBuilder::registerConstructor() method must be used instead.

In the example below, the mapper is taught how to instantiate an implementation of UuidInterface from package ramsey/uuid:

(new \CuyZ\Valinor\MapperBuilder())
    ->registerConstructor(
        // The static method below has return type `UuidInterface`;
        // therefore, the mapper will build an instance of `Uuid` when
        // it needs to instantiate an implementation of `UuidInterface`.
        Ramsey\Uuid\Uuid::fromString(...)
    )
    ->mapper()
    ->map(
        Ramsey\Uuid\UuidInterface::class,
        '663bafbf-c3b5-4336-b27f-1796be8554e0'
    );

JSON normalizer formatting optionscontributed by @boesing

By default, the JSON normalizer will only use JSON_THROW_ON_ERROR to encode non-boolean scalar values. There might be use-cases where projects will need flags like JSON_JSON_PRESERVE_ZERO_FRACTION.

This can be achieved by passing these flags to the new JsonNormalizer::withOptions() method:

namespace My\App;

$normalizer = (new \CuyZ\Valinor\MapperBuilder())
    ->normalizer(\CuyZ\Valinor\Normalizer\Format::json())
    ->withOptions(\JSON_PRESERVE_ZERO_FRACTION);

$lowerManhattanAsJson = $normalizer->normalize(
    new \My\App\Coordinates(
        longitude: 40.7128,
        latitude: -74.0000
    )
);

// `$lowerManhattanAsJson` is a valid JSON string representing the data:
// {"longitude":40.7128,"latitude":-74.0000}

The method accepts an int-mask of the following JSON_* constant representations:

  • JSON_HEX_QUOT
  • JSON_HEX_TAG
  • JSON_HEX_AMP
  • JSON_HEX_APOS
  • JSON_INVALID_UTF8_IGNORE
  • JSON_INVALID_UTF8_SUBSTITUTE
  • JSON_NUMERIC_CHECK
  • JSON_PRESERVE_ZERO_FRACTION
  • JSON_UNESCAPED_LINE_TERMINATORS
  • JSON_UNESCAPED_SLASHES
  • JSON_UNESCAPED_UNICODE

JSON_THROW_ON_ERROR is always enforced and thus is not accepted.

See official doc for more information: https://www.php.net/manual/en/json.constants.php

Features

  • Allow JSON normalizer to set JSON formatting options (cd5df9)
  • Allow mapping to array-key type (5020d6)
  • Handle interface constructor registration (13f69a)
  • Handle type importation from interface (3af22d)
  • Introduce unsealed shaped array syntax (fa8bb0)

Bug Fixes

  • Handle class tokens only when needed during lexing (c4be75)
  • Load needed information only during interface inferring (c8e204)

Other

  • Rename internal class (4c62d8)
1.11.0

Notable changes

Improvement of union types narrowing

The algorithm used by the mapper to narrow a union type has been greatly improved, and should cover more edge-cases that would previously prevent the mapper from performing well.

If an interface, a class or a shaped array is matched by the input, it will take precedence over arrays or scalars.

(new \CuyZ\Valinor\MapperBuilder())
    ->mapper()
    ->map(
        signature: 'array<int>|' . Color::class,
        source: [
            'red' => 255,
            'green' => 128,
            'blue' => 64,
        ],
    ); // Returns an instance of `Color`

When superfluous keys are allowed, if the input matches several interfaces, classes or shaped array, the one with the most children node will be prioritized, as it is considered the most specific type:

(new \CuyZ\Valinor\MapperBuilder())
    ->allowSuperfluousKeys()
    ->mapper()
    ->map(
        // Even if the first shaped array matches the input, the second one is
        // used because it's more specific.
        signature: 'array{foo: int}|array{foo: int, bar: int}',
        source: [
            'foo' => 42,
            'bar' => 1337,
        ],
    );

If the input matches several types within the union, a collision will occur and cause the mapper to fail:

(new \CuyZ\Valinor\MapperBuilder())
    ->mapper()
    ->map(
        // Even if the first shaped array matches the input, the second one is
        // used because it's more specific.
        signature: 'array{red: int, green: int, blue: int}|' . Color::class,
        source: [
            'red' => 255,
            'green' => 128,
            'blue' => 64,
        ],
    );

// ⚠️ Invalid value array{red: 255, green: 128, blue: 64}, it matches at
//    least two types from union.

Introducing AsTransformer attribute

After the introduction of the Constructor attribute used for the mapper, the new AsTransformer attribute is now available for the normalizer to ease the registration of a transformer.

namespace My\App;

#[\CuyZ\Valinor\Normalizer\AsTransformer]
#[\Attribute(\Attribute::TARGET_PROPERTY)]
final class DateTimeFormat
{
    public function __construct(private string $format) {}

    public function normalize(\DateTimeInterface $date): string
    {
        return $date->format($this->format);
    }
}

final readonly class Event
{
    public function __construct(
        public string $eventName,
        #[\My\App\DateTimeFormat('Y/m/d')]
        public \DateTimeInterface $date,
    ) {}
}

(new \CuyZ\Valinor\MapperBuilder())
    ->normalizer(\CuyZ\Valinor\Normalizer\Format::array())
    ->normalize(new \My\App\Event(
        eventName: 'Release of legendary album',
        date: new \DateTimeImmutable('1971-11-08'),
    ));

// [
//     'eventName' => 'Release of legendary album',
//     'date' => '1971/11/08',
// ]

Features

  • Improve union type narrowing during mapping (f73158)
  • Introduce AsTransformer attribute (13b6d0)

Bug Fixes

  • Handle single array mapping when a superfluous value is present (86d021)
  • Properly handle ArrayObject normalization (4f555d)
  • Properly handle class type with matching name and namespace (0f5e96)
  • Properly handle nested unresolvable type during mapping (194706)
  • Strengthen type tokens extraction (c9dc97)

Other

  • Reduce number of calls to class autoloader during type parsing (0f0e35)
  • Refactor generic types parsing and checking (ba6770)
  • Separate native type and docblock type for property and parameter (37993b)
1.10.0

Notable changes

Dropping support for PHP 8.0

PHP 8.0 security support has ended on the 26th of November 2023. Therefore, we are dropping support for PHP 8.0 in this version.

If any security issue was to be found, we might consider backporting the fix to the 1.9.x version if people need it, but we strongly recommend upgrading your application to a supported PHP version.

Introducing Constructor attribute

A long awaited feature has landed in the library!

The Constructor attribute can be assigned to any method inside an object, to automatically mark the method as a constructor for the class. This is a more convenient way of registering constructors than using the MapperBuilder::registerConstructor method, although it does not replace it.

The method targeted by a Constructor attribute must be public, static and return an instance of the class it is part of.

final readonly class Email
{
    // When another constructor is registered for the class, the native
    // constructor is disabled. To enable it again, it is mandatory to
    // explicitly register it again.
    #[\CuyZ\Valinor\Mapper\Object\Constructor]
    public function __construct(public string $value) {}

    #[\CuyZ\Valinor\Mapper\Object\Constructor]
    public static function createFrom(
        string $userName, string $domainName
    ): self {
        return new self($userName . '@' . $domainName);
    }
}

(new \CuyZ\Valinor\MapperBuilder())
    ->mapper()
    ->map(Email::class, [
        'userName' => 'john.doe',
        'domainName' => 'example.com',
    ]); // john.doe@example.com

Features

  • Introduce Constructor attribute (d86295)

Bug Fixes

  • Properly encode scalar value in JSON normalization (2107ea)
  • Properly handle list type when input contains superfluous keys (1b8efa)

Other

  • Drop support for PHP 8.0 (dafcc8)
  • Improve internal definitions string types (105281)
  • Refactor file system cache to improve performance (e692f0)
  • Remove unneeded closure conversion (972e65)
  • Update dependencies (c5627f)
1.9.0

Notable changes

JSON normalizer

The normalizer is able to normalize a data structure to JSON without using the native json_encode() function.

Using the normalizer instead of the native json_encode() function offers some benefits:

  • Values will be recursively normalized using the default transformations
  • All registered transformers will be applied to the data before it is formatted
  • The JSON can be streamed to a PHP resource in a memory-efficient way

Basic usage:

namespace My\App;

$normalizer = (new \CuyZ\Valinor\MapperBuilder())
    ->normalizer(\CuyZ\Valinor\Normalizer\Format::json());

$userAsJson = $normalizer->normalize(
    new \My\App\User(
        name: 'John Doe',
        age: 42,
        country: new \My\App\Country(
            name: 'France',
            code: 'FR',
        ),
    )
);

// `$userAsJson` is a valid JSON string representing the data:
// {"name":"John Doe","age":42,"country":{"name":"France","code":"FR"}}

By default, the JSON normalizer will return a JSON string representing the data it was given. Instead of getting a string, it is possible to stream the JSON data to a PHP resource:

$file = fopen('path/to/some_file.json', 'w');

$normalizer = (new \CuyZ\Valinor\MapperBuilder())
    ->normalizer(\CuyZ\Valinor\Normalizer\Format::json())
    ->streamTo($file);

$normalizer->normalize(/* … */);

// The file now contains the JSON data

Another benefit of streaming the data to a PHP resource is that it may be more memory-efficient when using generators — for instance when querying a database:

// In this example, we assume that the result of the query below is a
// generator, every entry will be yielded one by one, instead of
// everything being loaded in memory at once.
$users = $database->execute('SELECT * FROM users');

$file = fopen('path/to/some_file.json', 'w');

$normalizer = (new \CuyZ\Valinor\MapperBuilder())
    ->normalizer(\CuyZ\Valinor\Normalizer\Format::json())
    ->streamTo($file);

// Even if there are thousands of users, memory usage will be kept low
// when writing JSON into the file.
$normalizer->normalize($users);

Features

  • Introduce JSON normalizer (959740)

Bug Fixes

  • Add default transformer for DateTimeZone (acf097)
  • Detect circular references linearly through objects (36aead)

Other

  • Refactor attribute definition to include class definition (4b8cf6)
1.8.2

Bug Fixes

  • Allow callable type to be compiled (4a9771f)
1.8.1

Bug Fixes

  • Properly detect namespaced class in docblock (6f7c77)
1.8.0

Notable changes

Normalizer service (serialization)

This new service can be instantiated with the MapperBuilder. It allows transformation of a given input into scalar and array values, while preserving the original structure.

This feature can be used to share information with other systems that use a data format (JSON, CSV, XML, etc.). The normalizer will take care of recursively transforming the data into a format that can be serialized.

Below is a basic example, showing the transformation of objects into an array of scalar values.

namespace My\App;

$normalizer = (new \CuyZ\Valinor\MapperBuilder())
    ->normalizer(\CuyZ\Valinor\Normalizer\Format::array());

$userAsArray = $normalizer->normalize(
    new \My\App\User(
        name: 'John Doe',
        age: 42,
        country: new \My\App\Country(
            name: 'France',
            countryCode: 'FR',
        ),
    )
);

// `$userAsArray` is now an array and can be manipulated much more
// easily, for instance to be serialized to the wanted data format.
//
// [
//     'name' => 'John Doe',
//     'age' => 42,
//     'country' => [
//         'name' => 'France',
//         'countryCode' => 'FR',
//     ],
// ];

A normalizer can be extended by using so-called transformers, which can be either an attribute or any callable object.

In the example below, a global transformer is used to format any date found by the normalizer.

(new \CuyZ\Valinor\MapperBuilder())
    ->registerTransformer(
        fn (\DateTimeInterface $date) => $date->format('Y/m/d')
    )
    ->normalizer(\CuyZ\Valinor\Normalizer\Format::array())
    ->normalize(
        new \My\App\Event(
            eventName: 'Release of legendary album',
            date: new \DateTimeImmutable('1971-11-08'),
        )
    );

// [
//     'eventName' => 'Release of legendary album',
//     'date' => '1971/11/08',
// ]

This date transformer could have been an attribute for a more granular control, as shown below.

namespace My\App;

#[\Attribute(\Attribute::TARGET_PROPERTY)]
final class DateTimeFormat
{
    public function __construct(private string $format) {}

    public function normalize(\DateTimeInterface $date): string
    {
        return $date->format($this->format);
    }
}

final readonly class Event
{
    public function __construct(
        public string $eventName,
        #[\My\App\DateTimeFormat('Y/m/d')]
        public \DateTimeInterface $date,
    ) {}
}

(new \CuyZ\Valinor\MapperBuilder())
    ->registerTransformer(\My\App\DateTimeFormat::class)
    ->normalizer(\CuyZ\Valinor\Normalizer\Format::array())
    ->normalize(
        new \My\App\Event(
            eventName: 'Release of legendary album',
            date: new \DateTimeImmutable('1971-11-08'),
        )
    );

// [
//     'eventName' => 'Release of legendary album',
//     'date' => '1971/11/08',
// ]

More features are available, details about it can be found in the documentation.

Features

  • Introduce normalizer service (1c9368)

Bug Fixes

  • Allow leading zeros in numeric string in flexible mode (f000c1)
  • Allow mapping union of scalars and classes (4f4af0)
  • Properly handle single-namespaced classes (a53ef9)
  • Properly parse class name in same single-namespace (a462fe)
1.7.0

Notable changes

Non-positive integer

Non-positive integer can be used as below. It will accept any value equal to or lower than zero.

final class SomeClass
{
    /** [@var](https://github.com/var) non-positive-int */
    public int $nonPositiveInteger;
}

Non-negative integer

Non-negative integer can be used as below. It will accept any value equal to or greater than zero.

final class SomeClass
{
    /** [@var](https://github.com/var) non-negative-int */
    public int $nonNegativeInteger;
}

Features

  • Handle non-negative integer type (f444ea)
  • Handle non-positive integer type (53e404)

Bug Fixes

  • Add missing [@psalm-pure](https://github.com/psalm-pure) annotation to pure methods (004eb1)
  • Handle comments in classes when parsing types imports (3b663a)

Other

  • Add comment for future PHP version change (461898)
  • Fix some typos (5cf8ae)
  • Make NativeBooleanType a BooleanType (d57ffa)
1.6.1

Bug Fixes

  • Correctly handle multiline type alias in classes (c23102)
  • Handle integer key in path mapping modifier (9419f6)
  • Handle variadic parameters declared in docblock (f4884c)
1.6.0

Notable changes

Symfony Bundle

A bundle is now available for Symfony applications, it will ease the integration and usage of the Valinor library in the framework. The documentation can be found in the CuyZ/Valinor-Bundle repository.

Note that the documentation has been updated to add information about the bundle as well as tips on how to integrate the library in other frameworks.

PHP 8.3 support

Thanks to [@TimWolla], the library now supports PHP 8.3, which entered its beta phase. Do not hesitate to test the library with this new version, and report any encountered issue on the repository.

Better type parsing

The first layer of the type parser has been completely rewritten. The previous one would use regex to split a raw type in tokens, but that led to limitations — mostly concerning quoted strings — that are now fixed.

Although this change should not impact the end user, it is a major change in the library, and it is possible that some edge cases were not covered by tests. If that happens, please report any encountered issue on the repository.

Example of previous limitations, now solved:

// Union of strings containing space chars
(new MapperBuilder())
    ->mapper()
    ->map(
        "'foo bar'|'baz fiz'",
        'baz fiz'
    );

// Shaped array with special chars in the key
(new MapperBuilder())
    ->mapper()
    ->map(
        "array{'some & key': string}",
        ['some & key' => 'value']
    );

More advanced array-key handling

It is now possible to use any string or integer as an array key. The following types are now accepted and will work properly with the mapper:

$mapper->map("array<'foo'|'bar', string>", ['foo' => 'foo']);

$mapper->map('array<42|1337, string>', [42 => 'foo']);

$mapper->map('array<positive-int, string>', [42 => 'foo']);

$mapper->map('array<negative-int, string>', [-42 => 'foo']);

$mapper->map('array<int<-42, 1337>, string>', [42 => 'foo']);

$mapper->map('array<non-empty-string, string>', ['foo' => 'foo']);

$mapper->map('array<class-string, string>', ['SomeClass' => 'foo']);

Features

  • Add support for PHP 8.3 (5c44f8)
  • Allow any string or integer in array key (12af3e)
  • Support microseconds in the Atom / RFC 3339 / ISO 8601 format (c25721)

Bug Fixes

  • Correctly handle type inferring for method coming from interface (2657f8)
  • Detect missing closing bracket after comma in shaped array type (2aa4b6)
  • Handle class name collision while parsing types inside a class (044072)
  • Handle invalid Intl formats with intl.use_exceptions=1 (29da9a)
  • Improve cache warmup by creating required directories (a3341a)
  • Load attributes lazily during runtime and cache access (3e7c63)
  • Properly handle class/enum name in shaped array key (1964d4)

Other

  • Improve attributes arguments compilation (c4acb1)
  • Replace regex-based type parser with character-based one (ae8303)
  • Simplify symbol parsing algorithm (f260cf)
  • Update Rector dependency (669ff9)

[@TimWolla]: https://github.com/TimWolla

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
davejamesmiller/laravel-breadcrumbs
artisanry/parsedown
christhompsontldr/phpsdk
enqueue/dsn
bunny/bunny
enqueue/test
enqueue/null
enqueue/amqp-tools
milesj/emojibase
bower-asset/punycode
bower-asset/inputmask
bower-asset/jquery
bower-asset/yii2-pjax
laravel/nova
spatie/laravel-mailcoach
spatie/laravel-superseeder
laravel/liferaft
nst/json-test-suite
danielmiessler/sec-lists
jackalope/jackalope-transport