Convert input during mapping¶
This library can automatically map most inputs to the expected types, but sometimes it’s not enough, and custom logic must be applied to the data.
This is where mapper converters come in: they allow 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 converters 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'
Chaining converters¶
Converters can be chained, allowing multiple conversions 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(
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?!'
Attribute converters¶
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 readonly 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(…);
Converting source keys¶
When the input data uses different key names than the PHP codebase, key converters can be used to tell the mapper how to match source keys to object properties or shaped array elements.
Unlike value converters, key converters do not transform the input data itself, they only remap keys.
Note
Error messages will reference the original source key names, so the end user sees the key that was actually sent.
$mapper = (new \CuyZ\Valinor\MapperBuilder())
->registerKeyConverter(static function (string $key): string {
// Strips the `billing_` prefix from source keys
if (str_starts_with($key, 'billing_')) {
return substr($key, 8);
}
return $key;
})
->mapper();
final readonly class BillingAddress
{
public function __construct(
public string $street,
public string $city,
public string $country,
) {}
}
$source = [
'billing_street' => '221B Baker Street',
'billing_city' => 'London',
'billing_country' => 'UK',
];
// Works with classes
$mapper->map(BillingAddress::class, $source);
// Also works with shaped arrays
$mapper->map('array{street: string, city: string, country: string}', $source);
Chaining key converters¶
Multiple key converters can be registered and are applied as a pipeline; each one transforms the result of the previous one, in registration order:
(new \CuyZ\Valinor\MapperBuilder())
->registerKeyConverter(static function (string $key): string {
// Strip the "billing_" prefix
if (str_starts_with($key, 'billing_')) {
return substr($key, 8);
}
return $key;
})
->registerKeyConverter(
// Replace hyphens with underscores
static fn (string $key): string => str_replace('-', '_', $key),
)
->mapper()
->map('array{zip_code: string, country_name: string}', [
'billing_zip-code' => '62701',
'billing_country-name' => 'United Kingdom',
]);
Converters error handling¶
When a value converter or a key converter throws an exception, the mapper will properly handle it only if it follows the rules defined in the validation and error handling section.
Value converter errors¶
namespace My\App;
final class InvalidPriceException extends \RuntimeException { }
#[\CuyZ\Valinor\Mapper\AsConverter]
#[\Attribute(\Attribute::TARGET_PROPERTY)]
final class PriceInCents
{
public function map(string $value): int
{
if (preg_match('/^\d+(\.\d{2})?$/', $value) !== 1) {
throw new \My\App\InvalidPriceException(
"Invalid price format `$value`"
);
}
return (int)round((float)$value * 100);
}
}
final readonly class Product
{
public string $name;
#[\My\App\PriceInCents]
public int $priceInCents;
}
(new \CuyZ\Valinor\MapperBuilder())
->filterExceptions(function (\Throwable $error) {
if ($error instanceof \My\App\InvalidPriceException) {
return \CuyZ\Valinor\Mapper\Tree\Message\MessageBuilder::from($error);
}
throw $error;
})
->mapper()
->map(\My\App\Product::class, [
'name' => 'Widget',
'priceInCents' => 'not-a-price',
]);
// Invalid price format `not-a-price`
Key converter errors¶
Key converters follow the same error handling rules. When a key converter throws an exception, it is caught and reported against the original source key.
namespace My\App;
final class ForbiddenKeyException extends \RuntimeException { }
(new \CuyZ\Valinor\MapperBuilder())
->registerKeyConverter(static function (string $key): string {
if (str_starts_with($key, '__')) {
throw new \My\App\ForbiddenKeyException(
"Key `$key` is not allowed"
);
}
return strtolower($key);
})
->filterExceptions(function (\Throwable $error) {
if ($error instanceof \My\App\ForbiddenKeyException) {
return \CuyZ\Valinor\Mapper\Tree\Message\MessageBuilder::from($error);
}
throw $error;
})
->mapper()
->map('array{name: string}', [
'__forbidden' => 'hello',
]);
// Key `__forbidden` is not allowed