This is a utility that converts structured request data (for example: decoded JSON) into a complex object structure. The intended use of this utility is to receive request data and convert this into Command or Query object. The library is designed to follow a convention and does not validate input.
The object hydration can be achieved at zero expense, due to a ahead-of-time resolving of hydration steps using an optimized dumper.
That's a good question, so let's dig in. The primary driver for creating this tool was the desire to use objects (DTOs, Query and Command objects) to interact with a software model instead of using plain (raw) data. The use of objects makes code easier to understand as it provides clarity over what data is available, and what the data is intended for. The use of objects also prevents having to check for the availability and correctness of the data at every place of use, it can be checked once and trusted many times.
The use of objects also has down-sides, some more impactful than others. One of these is the burdon of converting data into objects, which is what this library aims to eliminate. By providing a predictable convention much of the conversion can be automated away. The use of instrumentation (in the form of property casters) expands these capabilities by allowing users to provide re-usable building blocks that provide full control as to how properties are converted from data to (complex) objects.
- Design goals
- Installation
- Usage
- Custom mapping key
- Mapping from multiple keys
- Property casting
- Casting to scalar values
- Casting to a list of scalar values
- Casting to a list of objects
- Casting to DateTimeImmutable objects
- Casting to Uuid objects (ramsey/uuid)
- Using multiple casters per property
- Creating your own property casters
- Static constructors
- Maximizing performance
This package was created with a couple design goals in mind. They are the following:
- Object creation should not be too magical (use no reflection for instantiation)
- There should not be a hard runtime requirement on reflection
- Constructed objects should be valid from construction
- Construction through (static) named constructors should be supported
composer require eventsauce/object-hydrator
By default, input is mapped by property name, and types need to match.
use EventSauce\ObjectHydrator\ObjectHydrator;
$hydrator = new ObjectHydrator();
class ExampleCommand
{
public function __construct(
public readonly string $name,
public readonly int $birthYear,
) {}
}
$command = $hydrator->hydrateObject(
ExampleCommand::class,
[
'name' => 'de Jonge',
'birthYear' => 1987
],
);
$command->name === 'de Jonge';
$command->birthYear === 1987;
Complex objects are automagically resolved.
class ChildObject
{
public function __construct(
public readonly string $value,
) {}
}
class ParentObject
{
public function __construct(
public readonly string $value,
public readonly ChildObject $child,
) {}
}
$command = $hydrator->hydrateObject(
ParentObject::class,
[
'value' => 'parent value',
'child' => [
'value' => 'child value',
]
],
);
use EventSauce\ObjectHydrator\MapFrom;
class ExampleCommand
{
public function __construct(
public readonly string $name,
#[MapFrom('birth_year')]
public readonly int $birthYear,
) {}
}
You can pass an array to capture input from multiple input keys. This is useful when multiple values represent a singular code concept. The array allows you to rename keys as well, further decoupling the input from the constructed object graph.
use EventSauce\ObjectHydrator\MapFrom;
class BirthDate
{
public function __construct(
public int $year,
public int $month,
public int $day
){}
}
class ExampleCommand
{
public function __construct(
public readonly string $name,
#[MapFrom(['year_of_birth' => 'year', 'month', 'day'])]
public readonly BirthDate $birthDate,
) {}
}
$hydrator->hydrateObject(ExampleCommand::class, [
'name' => 'Frank',
'year_of_birth' => 1987,
'month' => 11,
'day' => 24,
]);
When the input type and property types are not compatible, values can be cast to specific scalar types.
use EventSauce\ObjectHydrator\PropertyCasters\CastToType;
class ExampleCommand
{
public function __construct(
#[CastToType('integer')]
public readonly int $number,
) {}
}
$command = $hydrator->hydrateObject(
ExampleCommand::class,
[
'number' => '1234',
],
);
use EventSauce\ObjectHydrator\PropertyCasters\CastListToType;
class ExampleCommand
{
public function __construct(
#[CastListToType('integer')]
public readonly array $numbers,
) {}
}
$command = $hydrator->hydrateObject(
ExampleCommand::class,
[
'numbers' => ['1234', '2345'],
],
);
use EventSauce\ObjectHydrator\PropertyCasters\CastListToType;
class Member
{
public function __construct(
public readonly string $name,
) {}
}
class ExampleCommand
{
public function __construct(
#[CastListToType(Member::class)]
public readonly array $members,
) {}
}
$command = $hydrator->hydrateObject(
ExampleCommand::class,
[
'members' => [
['name' => 'Frank'],
['name' => 'Renske'],
],
],
);
use EventSauce\ObjectHydrator\PropertyCasters\CastToDateTimeImmutable;
class ExampleCommand
{
public function __construct(
#[CastToDateTimeImmutable('!Y-m-d')]
public readonly DateTimeImmutable $birthDate,
) {}
}
$command = $hydrator->hydrateObject(
ExampleCommand::class,
[
'birthDate' => '1987-11-24',
],
);
use EventSauce\ObjectHydrator\PropertyCasters\CastToUuid;
use Ramsey\Uuid\UuidInterface;
class ExampleCommand
{
public function __construct(
#[CastToUuid]
public readonly UuidInterface $id,
) {}
}
$command = $hydrator->hydrateObject(
ExampleCommand::class,
[
'id' => '9f960d77-7c9b-4bfd-9fc4-62d141efc7e5',
],
);
Create rich compositions of casting by using multiple casters.
use EventSauce\ObjectHydrator\PropertyCasters\CastToArrayWithKey;
use EventSauce\ObjectHydrator\PropertyCasters\CastToType;
use EventSauce\ObjectHydrator\MapFrom;
use Ramsey\Uuid\UuidInterface;
class ExampleCommand
{
public function __construct(
#[CastToType('string')]
#[CastToArrayWithKey('nested')]
#[MapFrom('number')]
public readonly array $stringNumbers,
) {}
}
$command = $hydrator->hydrateObject(
ExampleCommand::class,
[
'number' => [1234],
],
);
$command->stringNumbers === ['nested' => [1234]];
You can create your own property caster to handle complex cases that cannot follow the default conventions. Common cases for casters are union types or intersection types.
Property casters give you full control over how a property is constructed. Property casters are attached to properties using attributes, in fact, they are attributes.
Let's look at an example of a property caster:
use Attribute;
use EventSauce\ObjectHydrator\ObjectHydrator;
use EventSauce\ObjectHydrator\PropertyCaster;
#[Attribute(Attribute::TARGET_PARAMETER)]
class CastToMoney implements PropertyCaster
{
public function __construct(
private string $currency
) {}
public function cast(mixed $value, ObjectHydrator $hydrator) : mixed
{
return new Money($value, Currency::fromString($this->currency));
}
}
// ----------------------------------------------------------------------
#[Attribute(Attribute::TARGET_PARAMETER)]
class CastUnionToType implements PropertyCaster
{
public function __construct(
private array $typeToClassMap
) {}
public function cast(mixed $value, ObjectHydrator $hydrator) : mixed
{
assert(is_array($value));
$type = $value['type'] ?? 'unknown';
unset($value['type']);
$className = $this->typeToClassMap[$type] ?? null;
if ($className === null) {
throw new LogicException("Unable to map type '$type' to class.");
}
return $hydrator->hydrateObject($className, $value);
}
}
You can now use these as attributes on the object you wish to hydrate:
class ExampleCommand
{
public function __construct(
#[CastToMoney('EUR')]
public readonly Money $money,
#[CastUnionToType(['some' => SomeObject::class, 'other' => OtherObject::class])]
public readonly SomeObject|OtherObject $money,
) {}
}
Objects that require construction through static construction are supported. Mark the static method using
the Constructor
attribute. In these cases, the attributes should be placed on the parameters of the
static constructor, not on __construct
.
use EventSauce\ObjectHydrator\Constructor;
use EventSauce\ObjectHydrator\MapFrom;
class ExampleCommand
{
private function __construct(
public readonly string $value,
) {}
#[Constructor]
public static function create(
#[MapFrom('some_value')]
string $value
): static {
return new static($value);
}
}
Reflection and dynamic code paths can be a performance "issue" in the hot-path. To remove the expense, optimized version can be dumped. These dumps are generated PHP files that perform the same construction of classes as the dynamic would, in an optimized way.
You can dump a fully optimized hydrator for a known set of classes. This dumper will dump the code required for constructing the entire object tree, it automatically resolves the nested classes it can hydrate.
The dumped code is 3-10x faster than the reflection based implementation.
use EventSauce\ObjectHydrator\ObjectHydrator;
use EventSauce\ObjectHydrator\ObjectHydratorDumper;
$dumpedClassNamed = "AcmeCorp\\YourOptimizedHydrator";
$dumper = new ObjectHydratorDumper();
$classesToDump = [SomeCommand::class, AnotherCommand::class];
$code = $dumper->dump($classesToDump, $dumpedClassNamed);
file_put_contents('src/AcmeCorp/YourOptimizedHydrator.php');
/** @var ObjectHydrator $hydrator */
$hydrator = new AcmeCorp\YourOptimizedHydrator();
$someObject = $hydrator->hydrateObject(SomeObject::class, $payload);
When only need a cached version of the class and property definitions, you can dump those too.
use EventSauce\ObjectHydrator\DefinitionProvider;
use EventSauce\ObjectHydrator\DefinitionDumper;
$dumpedClassNamed = "AcmeCorp\\YourOptimizedDefinitionProvider";
$dumper = new DefinitionDumper();
$classesToDump = [SomeCommand::class, AnotherCommand::class];
$code = $dumper->dump($classesToDump, $dumpedClassNamed);
file_put_contents('src/AcmeCorp/YourOptimizedDefinitionProvider.php', $code);
/** @var DefinitionProvider $hydrator */
$hydrator = new AcmeCorp\YourOptimizedDefinitionProvider();
$definitionForSomeObject = $hydrator->provideDefinition(SomeObject::class);
You can use the construct finder package from The PHP League to find all classes in a given directory.
composer require league/construct-finder
use EventSauce\ObjectHydrator\DefinitionProvider;
$classesToDump = ConstructFinder::locatedIn($directoryName)->findClassNames();
$code = $dumper->dump($classesToDump, $dumpedClassNamed);
file_put_contents('src/AcmeCorp/YourOptimizedDefinitionProvider.php', $code);
This package is not unique, there are a couple implementations our there that do the same, similar, or more than this package does.