Skip to content

Extending the normalizer

This library provides a normalizer out-of-the-box that can be used as-is, or extended to add custom logic. To do so, transformers must be registered within the MapperBuilder.

A transformer can be a callable (function, closure or a class implementing the __invoke() method), or an attribute that can target a class or a property.

Note

You can find common examples of transformers in the next chapter.

Callable transformers

A callable transformer must declare at least one argument, for which the type will determine when it is used during normalization. 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',
// ]

Transformers can be chained. To do so, a second parameter of type callable must be declared in a transformer. This parameter — named $next by convention — can be used whenever needed in the transformer logic.

(new \CuyZ\Valinor\MapperBuilder())

    // The type of the first parameter of the transformer will determine when it
    // is used during normalization.
    ->registerTransformer(
        fn (string $value, callable $next) => strtoupper($next())
    )

    // Transformers can be chained, the last registered one will take precedence
    // over the previous ones, which can be called using the `$next` parameter.
    ->registerTransformer(
        /**
         * Advanced type annotations like `non-empty-string` can be used to
         * target a more specific type.
         * 
         * @param non-empty-string $value 
         */
        fn (string $value, callable $next) => $next() . '!'
    )

    // A priority can be given to a transformer, to make sure it is called
    // before or after another one. The higher priority, the sooner the
    // transformer will be called. The default priority is 0.
    ->registerTransformer(
        /**
         * @param non-empty-string $value 
         */
        fn (string $value, callable $next) => $next() . '?',
        priority: 100
    )

    ->normalizer(\CuyZ\Valinor\Normalizer\Format::array())
    ->normalize('Hello world'); // HELLO WORLD!?

Attribute transformers

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

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

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

namespace My\App;

#[\CuyZ\Valinor\Normalizer\AsTransformer]
#[\Attribute(\Attribute::TARGET_PROPERTY)]
final class Uppercase
{
    public function normalize(string $value, callable $next): string
    {
        return strtoupper($next());
    }
}

final readonly class City
{
    public function __construct(
        public string $zipCode,
        #[\My\App\Uppercase]
        public string $name,
        #[\My\App\Uppercase]
        public string $country,
    ) {}
}

(new \CuyZ\Valinor\MapperBuilder())
    ->normalizer(\CuyZ\Valinor\Normalizer\Format::array())
    ->normalize(
        new \My\App\City(
            zipCode: 'NW1 6XE',
            name: 'London',
            country: 'United Kingdom',
        ) 
    );

// [
//     'zipCode' => 'NW1 6XE',
//     'name' => 'LONDON',
//     'country' => 'UNITED KINGDOM',
// ]

If an attribute needs to transform the key of a property, it needs to declare a method named normalizeKey.

namespace My\App;

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

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

final readonly class Address
{
    public function __construct(
        #[\My\App\PrefixedWith('address_')]
        public string $road,
        #[\My\App\PrefixedWith('address_')]
        public string $zipCode,
        #[\My\App\PrefixedWith('address_')]
        public string $city,
    ) {}
}

(new \CuyZ\Valinor\MapperBuilder())
    ->normalizer(\CuyZ\Valinor\Normalizer\Format::array())
    ->normalize(
        new \My\App\Address(
            road: '221B Baker Street',
            zipCode: 'NW1 6XE',
            city: 'London',
        ) 
    );

// [
//     'address_road' => '221B Baker Street',
//     'address_zipCode' => 'NW1 6XE',
//     'address_city' => 'London',
// ]

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

(new \CuyZ\Valinor\MapperBuilder())
    ->registerTransformer(\Some\External\TransformerAttribute::class)
    ->normalizer(\CuyZ\Valinor\Normalizer\Format::array())
    ->normalize();

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
    ->registerTransformer(\My\App\SomeAttributeInterface::class)
    ->normalizer(\CuyZ\Valinor\Normalizer\Format::array())
    ->normalize();