patchlevel/hydrator
Hydrate objects to arrays and back without calling constructors. Supports final and readonly classes, nested structures, enums, dates, lazy hydration, field renaming, and metadata caching with a developer-friendly, fully typed API.
composer require patchlevel/hydrator
The package is framework-agnostic, so the first thing to do is build one hydrator instance for the whole application (usually in a DI container) and reuse it. Wiring is via a builder/factory that combines four collaborators:
use Patchlevel\Hydrator\Hydrator;
final class UserDto
{
public function __construct(
public readonly string $id,
public readonly string $email,
public readonly ?DateTimeImmutable $createdAt = null,
) {}
}
// Build once (see the "Hydrator" docs page for the exact builder signature in 2.x)
$hydrator = /* HydratorBuilder / Hydrator + metadata factory + guessers + middleware */;
$user = $hydrator->hydrate(UserDto::class, [
'id' => '01J...',
'email' => '[email protected]',
'createdAt' => '2025-01-01T12:00:00+00:00',
]);
$payload = $hydrator->extract($user);
// ['id' => '01J...', 'email' => '[email protected]', 'createdAt' => '2025-01-01T12:00:00+00:00']
Where to look first
docs/…/hydrator — the two core methods and constructor bypass behavior.docs/…/normalizer — which types are auto-resolved and how to plug your own.docs/…/guesser and docs/…/extensions — only needed once defaults don't fit.docs/…/middleware — new in 2.x, extended in 2.1 (skippable middleware + generated middleware). Read this before writing your third extension; a lot of "I need to intercept this" cases are middleware.docs/…/groups — new in 2.1.0; the first thing to reach for when one DTO needs two different array shapes.docs/…/lazy (PHP 8.4) and docs/…/cryptography — read before using; they change runtime semantics.First real use case: map a decoded JSON request body into a typed, readonly DTO instead of hand-writing new UserDto($data['id'], $data['email'], ...) with isset() guards. The same DTO can then be extracted straight into a json column. That round-trip — array → object → array — is the sweet spot of this library. The second real use case is 2.1's groups: the same DTO extracted with ['public'] for an API response and ['internal'] for the DB.
Request → DTO → validate → handler
Hydration is not validation. Treat it as a typed cast, then run your validator (Symfony Validator, Respect, etc.) on the hydrated object:
$dto = $hydrator->hydrate(CreateUser::class, $request->getPayload());
$violations = $validator->validate($dto);
Keep the class name a literal or an allow-listed constant — never take it from user input.
Persistence: JSON columns and payload blobs
// writing
$entity->payload = $hydrator->extract($command);
// reading
$command = $hydrator->hydrate(CreateUser::class, $entity->payload);
This is the pattern that pays off most: event-sourcing payloads, audit logs, outbox tables, and Doctrine json columns all become "store the array, hydrate on read", and schema changes are handled by renaming/excluding fields rather than hand-mapping.
Groups: one DTO, several shapes (2.1.0)
Instead of maintaining UserDto, UserApiDto, UserAdminDto and hand-copying fields, declare groups on the properties/promoted params and select the group per call:
final class UserDto
{
public function __construct(
#[Groups(['public', 'internal'])]
public readonly string $id,
#[Groups(['internal'])]
public readonly string $email,
#[Groups(['internal'])]
public readonly string $internalNote = '',
) {}
}
$hydrator->extract($user, ['groups' => ['public']]);
// ['id' => '01J...']
The typical wins: public API vs. internal payload, v1 vs. v2 consumers, and "what goes in the DB column" vs. "what goes over the wire". Register the groups extension once, pick groups at call time — don't bake group selection into separate hydrator instances.
Middleware: cross-cutting behavior with an escape hatch
Middleware wraps hydrate/extract and is the right layer for anything that inspects or rewrites the stream rather than one field: key translation, audit stamping, per-request context. 2.1's skippable middleware adds the ability to say "this field is out of scope" and stop the remaining stack from touching it — which is exactly how the groups extension does its filtering.
Rules that keep it sane:
Nested structures work out of the box
Backed enums, DateTimeImmutable, arrays of DTOs, and nested readonly objects are resolved by the default guessers/normalizers. Reach for attributes only when conventions fail:
final class UserDto
{
#[Rename('user_id')]
public readonly string $id;
#[Exclude]
public readonly string $internalToken = '';
#[Normalizer(new MyMoneyNormalizer())]
public readonly Money $balance;
}
Use #[Rename] for snake_case APIs without touching your PHP property names, and #[Exclude] for derived or sensitive fields that must never be extracted.
Extensions for cross-cutting behavior
Extensions are the right place for versioned payload handling (v1 → v2 migration of arrays), defaulting missing keys, or stamping metadata on extract. Keep them stateless and side-effect free; ordering matters when multiple extensions touch the same field. Groups now ship as an extension — read its source before writing your own field-filtering extension, you'll usually find it already covers you.
Custom normalizers for value objects
If you have Money, Uuid, Email, or PhoneNumber VOs, write one normalizer each and register it once. That single investment removes dozens of manual casts and keeps the array representation consistent across the app.
Lazy hydration (PHP 8.4+)
For large aggregates or deep graphs, #[Lazy]-marked properties hydrate as proxies and only materialize on first access. Use it on optional/heavy relations, not on required scalar fields.
Crypto-shredding for PII
Mark sensitive properties with the crypto attribute so extraction stores ciphertext and hydration needs the key. This is the strongest reason to pick this package over a hand-rolled mapper: deleting the key effectively erases the PII while leaving the rest of the payload intact. Combine with groups: keep ciphertext out of the public extraction entirely.
DI wiring
Register the hydrator as a singleton. If you're on Symfony/Laravel, define one service with the metadata factory, guesser chain and middleware stack; don't instantiate a hydrator inside repositories or controllers.
if ($id === '') throw ...) are bypassed. Validate after hydration, or put invariant checks in a factory that calls the hydrator.#[Normalizer].null. Decide per DTO whether absent means "skip" or "null" — default parameter values help, and an extension is the clean fix if you need consistent behavior. Test this explicitly; it's the most common source of subtle bugs.DateTimeInterface subclasses with non-default formats — configure the format in the normalizer rather than relying on ATOM.#[Rename] per field or install a naming-strategy guesser. Don't mix both approaches in one DTO.var_dump, serialize(), and strict instanceof checks can force initialization or surprise you. Don't combine lazy hydration with code that reflects over the object graph.hydrate($class, $data) will happily instantiate whatever class you pass. Always pass a compile-time constant or a value you've allow-listed.composer.json (^2.1 if you want groups/skippable middleware) and read the upgrade notes before composer update. 2.1.0 is a minor release — additive, no breaking changes, but the new attributes/middleware interfaces are worth adopting deliberately rather than incidentally.How can I help you explore Laravel packages today?