Object hydration looks too simple to optimize.
That was my assumption when the mapper I was using in one of my projects turned out to be dog slow.
I didn’t know much about hydrators or mappers at the time. From the outside, the job seemed simple: take an array and call a few public methods on an object. It looked like a small enough problem to solve myself.
I kept adding whatever my project needed. Eventually the workaround became a library, even if I was its only user. Once it solved my problem, I stopped working on it, and it sat in a private repository and rotted.
Recently, I picked it up again and started benchmarking choices I had made by intuition. I kept finding things to improve, and performance soon became the project.
I wanted to know how fast I could make a general-purpose hydrator.
Then I made the problem harder.
I wanted type conversion, nested objects, transformations, assertions, and support for private properties. But I didn’t want a class to pay for features it never used.
If a property had no assertion, there should be no assertion check. If it needed no transformation, there should be no mutator pipeline. A plain property should remain a plain assignment.
With every new feature, I checked the plain case again. If its generated code had changed, the feature was costing something even when it wasn’t used.
Move the decisions out of the hot path
Generating each assignment meant working out which key to read, whether the value needed converting, and how to reach the property.
HydraType works that out before the first object is hydrated. It reflects on the target class, inspects its properties and attributes, then generates a PHP class for that target.
For a plain integer property, the generated writer contains:
$object->id = (int) $data['id'];
The input key and cast are already in the code. The property is not inspected again.
The first problem: private properties
The generated assignment worked for public properties. Private properties required another approach.
I tried reflection first:
$property->setValue($object, $value);
I could cache the ReflectionProperty, but every assignment still went through setValue().
Then I tried a class-scoped closure. It runs in the scope of the target class, and it can write private properties directly:
$writer = Closure::bind(
static function (User $object, array $data): void {
$object->id = (int) $data['id'];
$object->name = (string) $data['name'];
},
null,
User::class,
);
At first, I created and bound a new writer for every object, then threw it away. The writer never changed, so I changed the hydrator to create it once and reuse it.
I also put every assignment inside the same closure. With reflection, hydrating five properties requires five calls to ReflectionProperty::setValue(). The generated writer makes one call containing five ordinary assignments.
The benchmark justified the change. Allocating an object and assigning five private properties took about 152 nanoseconds with the reusable writer, compared with 290 nanoseconds using generated calls to cached reflection properties. At one property, reflection was marginally faster. From two properties onward, the reusable writer won.
Creating a closure was not enough. The speedup came from reusing it and keeping all the assignments inside one call.
Keeping optional features out of the way
Fast property assignment covered only the simplest case. I still wanted conversions, nested objects, transformations, and assertions.
I could have sent every value through a runtime pipeline:
$value = $data['name'];
foreach ($mutators as $mutator) {
$value = $mutator->mutate($value);
}
foreach ($assertions as $assertion) {
$assertion->assert($value);
}
$object->name = $value;
That is flexible, but every property enters the same machinery. Even with empty lists, the hydrator still has to retrieve them and find that there is nothing to do.
Instead, I generate only the operations selected by that property.
A plain property could remain:
$object->id = (int) $data['id'];
A property can opt into transformations and assertions through attributes:
#[Trim]
#[NotEmpty]
private string $name;
For that property, the compiler could generate:
$value = trim((string) $data['name']);
if ($value === '') {
throw AssertionException::forProperty(...);
}
$object->name = $value;
The compiler emits the selected operations directly into the writer. For a plain property, those features simply do not exist.
What runs for each object
Once the writer was cached, hydrate() was roughly:
$writer = $this->writerFor($data);
$object = new User();
$writer($object, $data);
return $object;
Classes without constructors use new directly. When a constructor must be bypassed, HydraType retains a cached ReflectionClass:
$object = $this->reflectionClass->newInstanceWithoutConstructor();
So HydraType still uses reflection when it needs to bypass a constructor, just not to discover and write every property.
In production, the generated hydrators can be built before deployment and loaded from a read-only cache.
Did it work?
At this point I had code that looked fast. That did not mean it was fast, so I compared it with existing hydrators.
The benchmark used an object with five private typed properties and correctly typed camel-case input. Each operation created and fully hydrated a new object. Dependency setup, metadata preparation, code generation, and warm-up happened before the timed portion. The results are medians over nine samples of 20,000 objects.
Here is what I got:
| Hydrator | PHP 8.2 | Relative | PHP 8.5 | Relative |
|---|---|---|---|---|
| HydraType | 267.9 ns | 1.00× | 265.4 ns | 1.00× |
| Ocramius GeneratedHydrator | 307.9 ns | 1.15× | 322.5 ns | 1.22× |
| EventSauce generated | 338.4 ns | 1.26× | 371.3 ns | 1.40× |
| JoliCode AutoMapper 10 | — | — | 524.0 ns | 1.97× |
| Patchlevel Hydrator | 793.8 ns | 2.96× | 909.6 ns | 3.43× |
| Laminas ReflectionHydrator | 1,230.4 ns | 4.59× | 1,252.9 ns | 4.72× |
| Sunrise Hydrator | 9,662.5 ns | 36.07× | 9,534.8 ns | 35.93× |
| Symfony PropertyNormalizer | 8,499.0 ns | 31.72× | 9,783.9 ns | 36.87× |
| Valinor | 10,755.2 ns | 40.15× | 13,197.0 ns | 49.73× |
The benchmark scripts and full methodology ar available in the repostitory.
The 40× in the title comes from Valinor. It took about 40 times as long on PHP 8.2 and almost 50 times as long on PHP 8.5.
Ocramius GeneratedHydrator came closest, taking 1.15 times as long on PHP 8.2 and 1.22 times as long on PHP 8.5.
These libraries solve different problems, so the table is not a general ranking. It shows one thing: how long each took to create and hydrate this five-property object after setup.
For one object, none of these differences matter. A database query or network request will dwarf the hydration time.
The benchmark isolates the mapper itself. The difference starts to matter across a large result set, a queue backlog, or a data-processing job. It also shows how much overhead different designs add to the same work inside PHP.
The 40× number is fun. What I care more about is what happened as I added features.
After adding nested hydration, transformations, and assertions, a plain integer property still generated:
$object->id = (int) $data['id'];
It had not picked up a registry lookup, a dispatcher, or even an extra condition.
That was the rule I started with, and the generated code still obeyed it.
Top comments (0)