Valinor is a PHP mapper that focuses heavily on converting amorphous (mixed) data into well-structured/typed information.
It follows the “parse, don’t validate” principle.
Its job fits the problem space of structural validation, and for most PHP applications, it can aid in removing enormous amounts of code and logic around input handling.
function handleInvoiceCreation(Request $request): Response {
// Valinor is doing a lot of heavy lifting here: it throws detailed exceptions here too!
$command = $this->mapper->map(InvoiceCreationCommand::class, $request);
$this->commandBus->dispatch($command);
return new Response(204);
}
Beware that Valinor handles structural validation, not contextual validation (see also Form, Command, and Model Validation): it checks if the data fits a shape, not whether the data respects your domain invariants, such as “is this email address already in use?”
The mapper can also be used with strings representing complex types:
The following are all valid mapper usages:
$shoppingListItem = $mapper->map('array{quantity: int, description: string}', $input);
// repetitive, unsafe, annoying:
assert(is_int($row['quantity']));
assert(is_string($row['description']));
$shoppingList->add($shoppingListItem['quantity'], $shoppingListItem['description']);
$csvRows = $mapper->map('list<list{int, string, float}>')
foreach ($csvRows as $row) {
// repetitive, unsafe, annoying:
assert(is_int($row[0]));
assert(is_string($row[1]));
assert(is_float($row[2]));
$report->addStatistic(customers: $row[0], comment: $row[1], score: $row[2]);
}
$apiResponse = $mapper->map(Paginable::class . '<' . UserDetails::class . '>')
// we use `@var` here, which is a leap of faith, and may break in future
/** @var Paginable<UserDetails> $apiResponse */
importUsers($apiResponse->items);
processNextPage($apiResponse->nextPage);
(See also the official documentation about using these types).
These examples show how powerful the mapper is, but come at a great cost: static analysis tools like PHPStan, Psalm, Mago and Phan lose type inference whenever a string (instead of a class-string) is used as mapper requested shape.
These static analysis tools don’t understand that a pseudo type-string is given, and therefore, the above $shoppingListItem, $csvRows, $apiResponse are all inferred as mixed, leading to messy assertions/code being added around the mapper usage.
Since we don’t yet have a type-string pseudo-type, we can trick Valinor and the static analysis tools by extracting our type-string through an anonymous PHP class:
$shoppingListItem = $mapper->map(new readonly class (['quantity' => 1, 'description' => '']) {
/** @param array{quantity: int, description: string} $wrapped */
function __construct(public array $wrapped)
{
}
}::class, $input)->wrapped;
// this is now 100% type-safe!
$shoppingList->add($shoppingListItem['quantity'], $shoppingListItem['description']);
$csvRows = $mapper->map(new readonly class ([]) {
/** @param list<list{int, string, float}> $wrapped */
function __construct(public array $wrapped)
{
}
}::class, $input)->wrapped;
// look ma, no runtime assertions!
foreach ($csvRows as $row) {
$report->addStatistic(customers: $row[0], comment: $row[1], score: $row[2]);
}
$apiResponse = $mapper->map(new readonly class (new PaginatedResult(null, [])) {
/** @param PaginatedResult<UserDetails> $wrapped */
function __construct(public PaginatedResult $wrapped) {}
}::class, $input)->wrapped;
// no `@var` declarations anymore, hooray!
importUsers($apiResponse->items);
processNextPage($apiResponse->nextPage);
The approach outlined in this blogpost has few drawbacks:
wrapped sub-field, which is declared by our anonymous class: consider that, when serializing API response problemsHere’s the test code verifying the behavior we discussed in this blogpost:
<?php
declare(strict_types=1);
namespace Tests\Unit;
use CuyZ\Valinor\Mapper\Source\JsonSource;
use CuyZ\Valinor\MapperBuilder;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
/**
* @template Paginated of object
*/
final readonly class PaginatedResult
{
/**
* @param list<Paginated> $items
*/
public function __construct(
public string|null $nextPage,
public array $items,
)
{
}
}
final readonly class UserDetails
{
/** @param non-empty-string $username */
public function __construct(
public string $username,
public \DateTimeImmutable $registeredOn,
)
{
}
}
final class DummyTest extends TestCase
{
public function testDeSerializeMultipleFields(): void
{
$mapper = new MapperBuilder()->mapper();
$result = $mapper->map(
new readonly class (['quantity' => 1, 'description' => '']) {
/** @param array{quantity: int, description: string} $wrapped */
function __construct(public array $wrapped)
{
}
}::class,
new JsonSource('{"quantity": 1234, "description": "foo"}')
)->wrapped;
self::assertSame(1234, $result['quantity']);
self::assertSame('foo', $result['description']);
}
public function testDeSerializeList(): void
{
$mapper = new MapperBuilder()->mapper();
$result = $mapper->map(
new readonly class ([]) {
/** @param list<list{int, string, float}> $wrapped */
function __construct(public array $wrapped)
{
}
}::class,
new JsonSource('[[1, "foo", 12.34], [2, "bar", 34.56]]')
)->wrapped;
self::assertSame([[1, "foo", 12.34], [2, "bar", 34.56]], $result);
}
public function testDeSerializeGenericType(): void
{
$mapper = new MapperBuilder()->mapper();
$result = $mapper->map(
new readonly class (new PaginatedResult(null, [])) {
/**
* @param \Tests\Unit\PaginatedResult<\Tests\Unit\UserDetails> $wrapped
*/
function __construct(public PaginatedResult $wrapped)
{
}
}::class,
new JsonSource(<<<'JSON'
{
"nextPage": "page3Cursor",
"items": [
{"username": "ocramius", "registeredOn": "1784368800"},
{"username": "cspray", "registeredOn": "1784282400"}
]
}
JSON
))->wrapped;
self::assertEquals(
new PaginatedResult(
'page3Cursor',
[
new UserDetails(
'ocramius',
new DateTimeImmutable('@1784368800'),
),
new UserDetails(
'cspray',
new DateTimeImmutable('@1784282400'),
),
]
),
$result
);
}
}