Valinor

repository·master·Indexed 23 days ago

https://github.com/cuyz/valinor

A PHP library that transforms raw inputs, such as JSON or arrays, into validated, strongly-typed objects. Valinor leverages PHP types and static analysis annotations from PHPStan and Psalm (e.g., non-empty-string, list<T>, and generics) to ensure data integrity. It features a MapperBuilder for creating mappers, support for custom value and key converters via callables or attributes, and a customizable error system using MappingError and ICU formatting for localization.

Tokens
61.4K
Snippets
160
Records
188
Agent score
78%

What's inside Valinor

  1. What is Valinor?

    master

    Valinor is a PHP library designed to transform raw inputs (such as JSON or plain arrays) into strongly typed objects. It handles both the construction and the validation of these objects, ensuring they are in a perfectly valid state before they are used in your application.

    Key features include:

    • Type Safety: Guarantees object structure so you don't need manual type checks after construction.
    • Data Integrity: Prevents objects from ever entering an invalid state.
    • Advanced Type Support: Handles native PHP types as well as advanced types like shaped arrays, generics, and integer ranges (compatible with PHPStan and Psalm).
    • Precise Error Reporting: Provides human-readable error messages when validation fails.
    • Normalization: Includes a mechanism to transform objects back into data formats like JSON or CSV while preserving structure.
  2. Supported data transformations in the Normalizer

    master

    The Valinor normalizer handles several type transformations natively to ensure data is compatible with standard serialization functions like json_encode():

    • Objects: Converted to arrays based on their properties, regardless of visibility (public, protected, or private).
    • Dates: Formatted using the RFC 3339 format.
    • Backed Enums: Transformed to use their underlying value.
    • Unit Enums: Transformed to use their name.

    Custom transformations can be implemented by extending the normalizer.

  3. Map all request parameters to a single object using `asRoot`

    master

    Instead of mapping individual parameters to separate arguments, you can map an entire source (query or body) to a single parameter using the asRoot: true option on an attribute. This is useful for complex data structures or large numbers of parameters.

    You can map to a dedicated DTO class or to a shaped array.

    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
    {
        public function __invoke(
            #[FromRoute] string $authorId,
            #[FromQuery(asRoot: true)] ArticleFilters $filters,
        ): ResponseInterface { … }
    }
    
    // Alternatively, using a shaped array:
    
    final class ListArticlesWithArray
    {
        /**
         * @param array{status: non-empty-string, page?: positive-int, limit?: int<10, 100>}
         */
        public function __invoke(
            #[FromRoute] string $authorId,
            #[FromQuery(asRoot: true)] array $filters,
        ): ResponseInterface { … }
    }
  4. Use NormalizerBuilder to instantiate normalizers

    master

    In Valinor 2.x, NormalizerBuilder is the main entry point for normalizers. Methods in MapperBuilder that previously configured and returned normalizers (like registerTransformer() and normalizer()) have been removed to separate mapper and normalizer configuration APIs.

    $normalizer = (new \CuyZ\Valinor\NormalizerBuilder())
        ->registerTransformer(
            fn (\DateTimeInterface $date) => $date->format('Y/m/d')
        )
        ->normalizer(\CuyZ\Valinor\Normalizer\Format::array())
        ->normalize($someData);
  5. Use wildcard notation for class constants and enums

    master

    Valinor supports wildcard notation (*) when mapping to class constants or enum cases. This is useful when multiple constants or cases share a common prefix. When using a wildcard, the mapper will attempt to match the input value against any constant or case that starts with the specified prefix.

    Class Constants

    You can use the syntax ClassName::PREFIX* to match constants starting with PREFIX.

    Enums

    You can use the syntax EnumName::PREFIX* to match enum cases starting with PREFIX.

    // Example for class constants
    final class SomeClassWithConstants
    {
        public const FOO = 1337;
        public const BAR = 'bar';
        public const BAZ = 'baz';
    }
    
    $mapper = (new MapperBuilder())->mapper();
    
    $mapper->map('SomeClassWithConstants::BA*', 1337); // error
    $mapper->map('SomeClassWithConstants::BA*', 'bar'); // ok
    $mapper->map('SomeClassWithConstants::BA*', 'baz'); // ok
    
    // Example for enums
    enum SomeEnum: string
    {
        case FOO = 'foo';
        case BAR = 'bar';
        case BAZ = 'baz';
    }
    
    $mapper = (new MapperBuilder())->mapper();
    
    $mapper->map('SomeEnum::BA*', 'foo'); // error
    $mapper->map('SomeEnum::BA*', 'bar'); // ok
    $mapper->map('SomeEnum::BA*', 'baz'); // ok
  6. Use Configurators to reuse Mapper or Normalizer logic

    master

    Configurators are reusable pieces of configuration logic that implement MapperBuilderConfigurator or NormalizerBuilderConfigurator. They allow you to define complex setup logic once and apply it to multiple builders using the configureWith() method.

    MapperBuilderConfigurator

    Implement configureMapperBuilder(MapperBuilder $builder): MapperBuilder to customize how data is mapped to objects.

    NormalizerBuilderConfigurator

    Implement configureNormalizerBuilder(NormalizerBuilder $builder): NormalizerBuilder to customize how objects are normalized to other formats.

    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(...),
                );
        }
    }
    
    // Usage
    $result = (new \CuyZ\Valinor\MapperBuilder())
        ->configureWith(new \My\App\ApplicationMappingConfigurator())
        ->mapper()
        ->map(\My\App\User::class, ['id' => '...', 'name' => 'John Doe']);
  7. Extend the Normalizer with Transformers

    master

    You can customize how specific types are normalized by using transformers. Transformers can be implemented in two ways:

    1. Global Transformers: Registered via registerTransformer() on the MapperBuilder. These apply to any instance of the target type found during normalization (e.g., a callable that formats all DateTimeInterface objects).
    2. Attribute Transformers: Defined as PHP Attributes on class properties. This allows for granular, per-property control over how a value is transformed.
    // Global Transformer Example
    (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'),
            )
        );
    
    // Attribute Transformer Example
    #[\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'),
            )
        );
  8. Avoid object constructor collisions in MapperBuilder

    master

    When registering multiple constructors for the same class using registerConstructor(), Valinor checks for collisions. A collision occurs if two or more registered constructors have the same signature (the same parameter names). If a collision is detected, an exception will be thrown.

    Example of a collision:

    final class SomeClass
    {
        public static function constructorA(string $foo, string $bar): self { /* ... */ }
        public static function constructorB(string $foo, string $bar): self { /* ... */ }
    }
    
    (new \CuyZ\Valinor\MapperBuilder())
        ->registerConstructor(
            SomeClass::constructorA(...),
            SomeClass::constructorB(...),
        )
        ->mapper();
    
    // This will throw an exception: A collision was detected
  9. Map all HTTP parameters to a single object using `asRoot`

    master

    When dealing with many parameters or complex structures, use the asRoot: true option within an attribute to map all values from a specific source into a single parameter (like a DTO or a shaped array).

    This works with both #[FromQuery(asRoot: true)] and #[FromBody(asRoot: true)].

    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
    {
        public function __invoke(
            #[FromRoute] string $authorId,
            #[FromQuery(asRoot: true)] ArticleFilters $filters,
        ): ResponseInterface { … }
    }
  10. Chain value converters with priority

    master

    Converters can be chained together. To allow a converter to pass control to the next one in the chain, declare a second callable parameter (often named $next) and call it with the transformed value.

    Converters can be ordered using a priority integer. Higher priority values are executed earlier. The default priority is 0.

    (new \CuyZ\Valinor\MapperBuilder())
        ->registerConverter(
            fn (string $value, callable $next): string => $next(strtoupper($value))
        )
        ->registerConverter(
            fn (string $value, callable $next): string => $next($value . '!'),
            priority: -10,
        )
        ->registerConverter(
            fn (string $value, callable $next): string => $next($value . '?'),
            priority: 10,
        )
        ->mapper()
        ->map('string', 'hello world'); // 'HELLO WORLD?!'
  11. How attribute converters work

    master

    Attribute converters provide granular control during mapping by targeting specific classes or properties. Unlike callable converters which target any value, attribute converters are triggered only when their specific attribute is applied to a property or function parameter.

    To use an attribute converter:

    1. Define an attribute class and mark it with the \CuyZ\Valinor\Mapper\AsConverter attribute.
    2. Implement a map method in the attribute class. The map method must have a mandatory first parameter (the value to convert) and an optional second callable parameter (the next step in the mapping chain).
    3. Apply the attribute to a property in a class or a parameter in a function.
    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',
        ]);
  12. Use Mapper Converters to apply custom mapping logic

    master

    In Valinor 2.x, you can hook into the mapping process using Mapper Converters. A converter is a callable that takes an input type and returns a target type. Valinor uses these type annotations to determine when to apply the converter.

    Basic Converter

    To convert a value (e.g., converting a string to uppercase), register a simple callable with MapperBuilder::registerConverter().

    Chained Converters

    Converters can be chained by declaring a second callable parameter in your function. This parameter represents the next converter in the chain. This allows you to wrap transformations.

    Priority

    You can control the execution order using the priority parameter. A higher priority value means the converter is executed earlier. The default priority is 0.

    // Basic converter
    (new \CuyZ\Valinor\MapperBuilder())
        ->registerConverter(
            fn (string $value): string => strtoupper($value)
        )
        ->mapper()
        ->map('string', 'hello world'); // 'HELLO WORLD'
    
    // Chained converters with priority
    (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?!'