jakubboucek / hydrator
Fast bidirectional hydrator between typed PHP entities and database rows or other data formats, built for modern PHP with property hooks support
Requires
- php: ~8.4
Requires (Dev)
- ext-pdo_sqlite: *
- nette/database: ^3.2.8 || ^4.0
- nette/tester: 2.6.1
- phpstan/phpstan: 2.2.7
Suggests
- nette/database: Seamless hydration of Row, ActiveRow and Selection with the NetteDatabase format
This package is auto-updated.
Last update: 2026-08-23 07:28:15 UTC
README
Fast bidirectional hydrator between typed PHP entities and database rows (or other data formats), built for modern PHP.
Entities are plain data objects: typed public properties, property hooks, no magic getters/setters and no mandatory attributes. The only contract is the empty Entity marker interface — it keeps the entity a plain object while letting the hydrator (and your IDE) refuse foreign objects early instead of failing later with confusing field-mismatch errors. The hydrator maps entities to and from associative data — a database row, a raw PDO result or any other representation described by a format.
Warning
The library is in development stage (0.x versions): the API may change between minor versions until it stabilizes.
Why another hydrator
General mapping libraries struggle with entities written in modern PHP style. This library is designed around them by design:
- Property hooks aware — the hydrator works with the stored state of backed public properties as an ordinary external caller: hooks on backed properties apply as for any other code,
private(set)/protected(set)/readonlyproperties are extracted but never written, and virtual properties (no backing store) are ignored entirely — get/set hooks stay a private interface between the entity and the application. - Partial updates — extraction distinguishes uninitialized from null: only initialized properties produce fields, so a partially filled entity naturally becomes a partial
UPDATE;isInitialized()andgetInitializedPropertyNames()let application code ask what a partial entity carries. - Pass-through of already-typed values — layers like nette/database return
DateTimeImmutable,boolandDateIntervalinstances; the hydrator accepts them as-is instead of demanding strings. - Database type nuances —
DATEvsDATETIME(#[Type\Date]) andTIMEcolumns (day-scoped#[Type\Time]or full-rangeDateInterval) are first-class citizens. - Deterministic time zones — every hydrated date-time is normalized into the application time zone.
- Performance — reflection runs once per entity class; per-row work is a plain loop over precomputed metadata (hundreds of thousands of rows per second). The library never caches data or entities, only its own metadata: data sets are processed as lazy single-pass streams.
Built for gradual modernization
The hydrator is designed to work as an intermediate step when modernizing a legacy application: it requires no change of the database-access paradigm and no changes to the database structure. Retrofitting Doctrine or another full ORM into a large existing codebase tends to be a demanding, all-or-nothing endeavor; typed entities backed by this library can instead be adopted piecemeal — one table or one query at a time — while the rest of the application keeps its existing database layer, making it a practical vehicle for refactoring database access gradually.
The only hard requirement is PHP 8.4. For an older application that is usually a far smaller obstacle than a data-access rewrite: a PHP upgrade is well supported by automated tooling (such as Rector), whereas replacing the database layer of a grown codebase rarely is.
Installation
composer require jakubboucek/hydrator
Requires PHP 8.4+. No runtime dependencies.
Usage
use JakubBoucek\Hydrator\HydratorFactory; use JakubBoucek\Hydrator\Format\NetteDatabase; $factory = new HydratorFactory( format: NetteDatabase::class, // preferred format timeZone: new DateTimeZone('Europe/Prague'), // app time zone (defaults to PHP default) ); $articles = $factory->for(Article::class); // single row: array or Traversable (Nette Row / ActiveRow) $article = $articles->fromData($explorer->table('article')->get(1)); // whole result: lazy single-pass stream; a Selection keys it by its primary key foreach ($articles->fromDataSet($explorer->table('article')) as $id => $article) { // ... } // partial update: only initialized properties are extracted $patch = new Article(); $patch->title = 'Updated title'; $explorer->table('article')->where('id', $id)->update($articles->toData($patch));
The entity is a plain object:
use JakubBoucek\Hydrator\Attribute\Type; use JakubBoucek\Hydrator\Entity; class Article implements Entity { public int $id; public string $title; public ?string $note; public bool $published; public DateTimeImmutable $createdAt; #[Type\Date] // DATE column: no time part public DateTimeImmutable $publishedOn; public DateInterval $readingTime; // TIME column public ArticleStatus $status; // BackedEnum, mapped by backing value public string $label { // virtual property: ignored by the hydrator get => "#{$this->id} {$this->title}"; } }
Property names map to field names by convention (camelCase ↔ snake_case by default, defined by the format).
Data sets and streaming
fromDataSet() returns an EntitySet — a lazy, single-pass stream of hydrated entities. Nothing is buffered and the data source is not touched until the first consumption; rows hydrate one by one as you iterate, so a result of any size streams in constant memory.
Stream keys are transparent: the keys of the source iteration pass through unchanged (a nette/database Selection keys its rows by the table primary key, a plain list yields sequential keys). To re-key the stream, pass keyBy: with an entity property name — the key is read from the hydrated entity, so it carries the property's type (a stringifying driver still yields int keys) and the format's naming convention never leaks into calling code. The property must be a hydrated, non-nullable int or string property — anything else fails loudly before the source is touched:
// stream keyed by the source (Selection = primary key) foreach ($articles->fromDataSet($explorer->table('article')) as $id => $article) { /* … */ } // re-keyed by a property, materialized into a lookup map $byId = $articles->fromDataSet($pdo->query('SELECT * FROM article'), keyBy: 'id')->collectMap(); // materialized into a plain list, keys dropped $today = $items->fromDataSet($rows)->collectList();
The set is strictly single-pass: once consumed — iterated or collected, even partially — a second consumption attempt throws a StreamException. The library never keeps data or entities around; holding onto results is the application's decision. For data sets that are small by design that decision is first-class: the collect* terminals materialize the stream into an array — collectList() discards keys, collectMap() preserves them (duplicate keys overwrite, as with iterator_to_array()). The vocabulary is intentional: lazy streaming is the default state, materialization is a conscious, named, greppable termination of the stream.
There is deliberately no eager variant on the hydrator or factory and no re-iterable set — the lazy path stays the easiest one, and an API that buffered or re-queried behind the scenes would only move the memory decision out of sight.
Strictness
Every writable property requires its field in data: a missing field, a null for a non-nullable property or a value of an unexpected type throws an exception with the entity class, property and field name in the message. Extra fields in data with no matching property are silently ignored, and fields of non-writable backed properties (readonly, private(set)) are never required. Virtual properties have no stored state, so the hydrator ignores them entirely — a data key matching a virtual property's would-be field is foreign data like any other extra key. All library exceptions implement the JakubBoucek\Hydrator\Exception\HydratorException marker interface.
Legacy zero dates ('0000-00-00', '0000-00-00 00:00:00') hydrate as null with an E_USER_WARNING — matching nette/database's behavior — so a non-nullable property over such data fails loudly instead of receiving a nonsense date.
Both strictness rules have explicit, per-call switches on fromData()/fromDataSet():
allowPartial: truetolerates missing fields — the corresponding properties stay uninitialized, mirroring the partial extraction: a sparseSELECT id, titlehydrates a sparse entity whosetoData()produces exactly those fields back. Combined withinto:it acts as a merge/patch — absent fields keep the target's current values. Beware that the strict default is what catches column-name typos; pairallowPartialwithrejectUnknownto keep that protection.rejectUnknown: truetightens the opposite direction: a data key that maps to no entity property throws (known keys cover all backed mapped properties including the non-writable ones, so a full-row roundtrip passes; a key matching a virtual property's field is rejected like any unknown key, with a message pointing out the match).
There is deliberately no factory-wide default for either switch — tolerance is a per-call decision, not a mode.
Asking what a partial entity carries
A partially hydrated entity is the patch — but application code cannot just read its properties, because reading an uninitialized typed property is a fatal Error. For most cases PHP itself has the answer: isset() and the ??/??= operators are safe on uninitialized typed properties, and for a non-nullable property they answer exactly:
$favorite->position ??= $this->nextPosition($favorite->userId); // fallback only when not set if (isset($entity->id)) { /* update */ } else { /* insert */ } // upsert dispatch
Where the native idioms fall short, the hydrator answers — from the backing store, in property vocabulary:
$hydrator->isInitialized($entity, 'note'); // true also for a stored null $hydrator->getInitializedPropertyNames($entity); // e.g. ['id', 'note'] — names only, never values $hydrator->getInitializationState($entity); // InitializationState::Empty / Partial / Complete
- Nullable properties with patch semantics —
isset()conflates a storednull("set the column to NULL") with uninitialized ("don't touch it");isInitialized()keeps them apart. - Entities with get hooks —
isset()invokes the hook, which crashes on an uninitialized backing; the hydrator reads the backing store via reflection and never invokes hooks, so no user code runs. - Generic code — a generic save/patch routine can branch on
getInitializedPropertyNames()without touching a single value. - Aggregate questions —
getInitializationState()classifies the whole entity asEmpty,PartialorComplete, e.g. to skip work on an empty patch. The state is relative to the entity's own declaration, nothing more:Completepromises neither "ready to INSERT" nor "safe to read", and an entity with no mapped properties reportsEmpty.
In a repository where extraction happens anyway, the cheapest empty-patch guard needs no introspection at all — extract first and test the payload (this reads no field names or values, and an update() needs the guard anyway):
$data = $hydrator->toData($changes); if ($data === []) { return; } $table->update($data);
An unknown property name throws a MetadataException — a typo is a bug, not "not set". So does asking about a virtual property: it has no stored state to ask about. The promise is precisely the stored value is initialized, not reading is safe — a get hook may still fail on its own uninitialized dependencies. Write get hooks to tolerate uninitialized state (or accept the consequences): the hydrator never forces partial entities on you — the strict default requires every field — it only enables them.
Important
toData() is not the way to ask this question. Its output is addressed to the storage driver — field names and format-encoded values. The point of the hydrator is that application code never speaks that vocabulary.
Formats
A format describes how values are represented in data: the field naming convention and the codecs for booleans, date-times, dates and intervals. Formats are stateless and identified by their class name:
Format\NetteDatabase— for nette/database, which already converts values on both sides: instances pass through, booleans stay booleans.Format\Mysql— for raw PDO/mysqli: date-times as'Y-m-d H:i:s', dates as'Y-m-d', booleans as0/1, TIME as'HH:MM:SS'strings.Format\Json— for decoded JSON payloads (APIs): property names as-is (camelCase), date-times as RFC 3339 (a foreign offset is recalculated into the app time zone), dates as'Y-m-d', native booleans, times as'HH:MM:SS'strings.
Export values by format
What toData() produces for each property type:
| Property type | NetteDatabase | Mysql | Json |
|---|---|---|---|
int, float, string |
as-is | as-is | as-is |
bool |
bool |
1 / 0 |
bool |
BackedEnum |
backing value | backing value | backing value |
DateTimeImmutable |
instance 1) | 'Y-m-d H:i:s' 2) |
RFC 3339 2) |
#[Type\Date] |
instance 1) | 'Y-m-d' 2) |
'Y-m-d' 2) |
#[Type\Time] |
'H:i:s' 3) |
'H:i:s' 3) |
'H:i:s' 3) |
DateInterval |
instance 1) | 'HH:MM:SS' 4) |
'HH:MM:SS' 4) |
Struct |
JSON string 5) | JSON string 5) | nested array |
| custom types | by native type | by native type | by native type |
mixed / untyped |
as-is | as-is | as-is |
1) Instance pass-through — the database layer formats it itself.
2) Rendered in the application time zone.
3) Wall clock of the value, no zone conversion; fractional seconds appended when non-zero. A plain time string is used even with nette/database — Nette would write an instance as a full 'Y-m-d H:i:s'.
4) Full TIME domain kept: sign, hours over 24, fractional seconds.
5) The struct's own toJson() rendering; an empty struct is stored as NULL.
The #[Fraction] and #[DateFormat] attributes override these default renderings — see Attributes.
Hydration inputs by format
What fromData() accepts for each property type:
| Property type | NetteDatabase | Mysql | Json |
|---|---|---|---|
int, float, string |
scalar (cast) | scalar (cast) | scalar (cast) |
bool |
bool, 0/1, '0'/'1' |
bool, 0/1, '0'/'1' |
bool only |
BackedEnum |
backing value 6) | backing value 6) | backing value 6) |
DateTimeImmutable |
instance, string 7) | instance, string 7) | instance, string 7) |
#[Type\Date] |
instance, string 7) | instance, string 7) | instance, string 7) |
#[Type\Time] |
instance, 'HH:MM:SS', DateInterval 8) |
instance, 'HH:MM:SS' 8) |
instance, 'HH:MM:SS' 8) |
DateInterval |
instance, 'HH:MM:SS' 9) |
instance, 'HH:MM:SS' 9) |
instance, 'HH:MM:SS' 9) |
Struct |
JSON string, NULL 10) |
JSON string, NULL 10) |
array, null 10) |
| custom types | by native type | by native type | by native type |
mixed / untyped |
anything, as-is | anything, as-is | anything, as-is |
6) int or string, cast to the enum backing type, mapped via ::from().
7) Any DateTimeInterface instance is converted into the application time zone; a naive string is interpreted in it, a string carrying its own offset is recalculated into it.
8) Day range enforced (00:00:00 <= x < 24:00:00); a DateInterval beyond the day scope (Nette delivers those for MySQL TIME) is rejected.
9) Full TIME domain: sign, hours over 24, fractional seconds.
10) Parsed by the struct itself (fromJson/fromArray); NULL hydrates into an empty struct instance — see Structs.
Custom format = subclass:
class UpperSnake extends Mysql { protected function createNameConverter(): NameConverter { return new MyUpperSnakeConverter(); } }
Thanks to instanceof scope matching a subclass automatically inherits attribute scopes targeting its parents.
Attributes
Attributes are opt-in escape hatches for edge cases — the default mapping is fully conventional.
#[Name] overrides the field name, optionally scoped to formats (a concrete class, ancestor, or a family interface like Format\DatabaseFormat). Attributes are evaluated top-down, first match wins — declare more specific scopes first; an unscoped attribute is a catch-all and must come last:
use JakubBoucek\Hydrator\Attribute\Name; use JakubBoucek\Hydrator\Format\DatabaseFormat; class Legacy { #[Name('some__name', [DatabaseFormat::class])] // all database formats #[Name('someName')] // any other format public string $someName; }
#[Type\Date] refines a DateTimeImmutable property to a date-only value (see above).
#[Type\Time] refines a DateTimeImmutable property to a time-of-day value: the date is pinned to 0001-01-01 — a date that predates DST rules, so every wall time on it exists exactly once — and string formats represent it as 'HH:MM:SS'.
A TIME column can therefore be mapped in two ways; pick by the domain of the column:
DateIntervalproperty — full MySQL TIME compatibility: sign and hours over 24 ('-838:59:59'…'838:59:59'), values aDateTimecannot hold. MySQL documents TIME as dual-purpose — time of day and elapsed time — and this mapping carries all of it.#[Type\Time]onDateTimeImmutable— strictly day-scoped (00:00:00 <= x < 24:00:00, enforced), with the comfort of the DateTime API.
Note
With nette/database on MySQL, TIME columns arrive as DateInterval instances; the NetteDatabase format converts them for #[Type\Time] properties within the day range and rejects values beyond it — such columns belong to a DateInterval property.
#[Fraction] controls fractional seconds on export — the analogy of DATETIME(n)/TIME(n) column precision — for date-time, time and interval properties. Without it the format defaults apply (date-times render without a fraction, times and intervals append one when non-zero); with it the rendering is strict: exactly digits places (zero-padded, truncated), digits: 0 never renders one, omitZero: true drops a zero-valued part:
#[Fraction(6)] // DATETIME(6): always six places public DateTimeImmutable $measuredAt; #[Fraction(3, omitZero: true, formats: [Json::class])] // milliseconds, only when non-zero public DateTimeImmutable $processedAt;
#[DateFormat] sets a custom output pattern (a native PHP date format string) for date-time, date and time properties. The same pattern drives both directions — export via format(), import via DateTimeImmutable::createFromFormat(), strictly, with no fallback to constructor parsing — so a pattern capturing the full value roundtrips losslessly, and a lossy pattern (e.g. without seconds) zeroes the uncaptured parts deterministically instead of crashing:
#[DateFormat('U')] // unix timestamp public DateTimeImmutable $syncedAt;
#[DateFormat] is deliberately not available for DateInterval: DateInterval::format() has no parsing counterpart in PHP, so the bidirectional promise could not be kept — exotic interval renderings belong to a custom format subclass.
Both attributes are scoped like #[Name] and mutually exclusive per (property, format) — one property may combine a strict fraction for databases with a pattern for JSON:
#[Fraction(3, formats: [DatabaseFormat::class])] #[DateFormat('d.m.Y H:i', formats: [Json::class])] public DateTimeImmutable $mixedUse;
Note
With #[Fraction] or #[DateFormat] the NetteDatabase format exports a finished string instead of the instance — Nette's own 'Y-m-d H:i:s' formatting would drop the fraction, which is the very motivation: DATETIME(6) columns keep their microseconds.
Structs
A struct is an autonomous structure stored in a single JSON column: for the database the column stays an ordinary string, but the entity works with it as a typed object. The hydrator never looks inside — it hands the serialized value to the struct and asks for it back; parsing, rendering and emptiness are fully the struct's domain. The Struct interface carries two representations: fromJson/toJson (databases) and fromArray/toArray (plain data, used by the Json format).
use JakubBoucek\Hydrator\Struct\BaseStruct; use JakubBoucek\Hydrator\Entity; use JakubBoucek\Hydrator\Struct\NoteList; class AddressStruct extends BaseStruct { public ?string $city = null; public ?string $street = null; public ?string $zip = null; } class Member implements Entity { public int $id; public AddressStruct $address; // non-nullable: an instance always exists public NoteList $notes; } $member = $members->fromData($row); $member->address->city = 'Plzeň'; // writable at any time, no null-checks $member->notes->add('Paid by wire', 'admin', new DateTimeImmutable());
Rules of the mechanism:
- An instance always exists: a
NULLcolumn hydrates into an empty struct, so struct properties are declared non-nullable and are writable at any time. Partial-update semantics stay untouched (an uninitialized property still produces no field). - An empty struct is stored as
NULL— emptiness is defined by the struct itself (toJson()returning null). Declared-fields structs never store'{}'or'[]'; the verbatimRawJsonObjecttreats a present empty document as a value (see below). - In the Json format structs travel as nested arrays and emptiness is explicit (
[]). - Structs must be constructible without arguments, and the array representation must stay plain JSON-serializable data — no objects inside (dates as strings).
Bundled implementations: BaseStruct (declared fields; unknown keys are dropped and nulls filtered — documented lossy traits), DynamicObject (lossless free-form, an stdClass analogy), JsonObject (mutable whole-payload array, see below), RawJsonObject (verbatim read-only document, see below) and the list-shaped showcases TagList and NoteList (add…/remove…, iteration, toText()).
Whole-payload structs: JsonObject
When the application just needs the whole decoded document as one editable array — a config blob, a preferences column — JsonObject holds it in a single public $value: decode on load, encode on store, nothing else. No declared fields (that is BaseStruct), no keys-as-properties (DynamicObject), no byte-level fidelity (RawJsonObject/RawJsonValue — this class re-encodes by design).
class Device implements Entity { public int $id; public JsonObject $config; } $device = $hydrator->fromData($row); $device->config->value['mode'] = 'boost'; // a NULL column hydrated into an $device->config->value['tags'][] = 'new'; // empty instance — no null checks $explorer->table('device')->where('id', $device->id)->update($hydrator->toData($device));
- The object-root contract lives on the serialized boundary.
fromJson()accepts only a{root;toJson()guarantees one by casting the root — and only the root — to an object, so nested lists stay proper JSON arrays (neverJSON_FORCE_OBJECT, which would mangle them too). The PHP side is deliberately tolerant:$valueis a plain array and a list shape renders as an object with numeric keys (['a', 'b']→'{"0":"a","1":"b"}'). That coercion is the only lossless behavior PHP allows —'{"0":"a"}'decodes into a PHP list, so rejecting list shapes on export would break the roundtrip of a valid document. - Emptiness follows the struct convention: an empty
$valuestores asNULL, aNULLcolumn hydrates into an empty writable instance. For aNOT NULLcolumn, a one-line subclass override does it:public function toJson(): string { return parent::toJson() ?? '{}'; }(the same recipe works for every bundled struct). - Content is guarded only where encoding happens. PHP cannot watch the inside of an array on assignment, so an unserializable value (
INF, resources, invalid UTF-8) fails with aValueExceptionat extraction into a serialized representation (the database formats), wrapped with property context by the engine. TheJsonformat's payload is decoded data — the hydrator never encodes there, so such a value passes throughtoData()(as any struct content does) and fails only when the application encodes the payload.
Verbatim documents: RawJsonObject
When a column carries a foreign JSON document — a webhook payload, an API response — the document must be stored as-is while the application reads only the fields it cares about. BaseStruct drops unknown keys, and even the key-lossless DynamicObject re-encodes on the way out, which changes number representation (1e5 → 100000.0, integers beyond 253 lose precision), escaping, and duplicate keys. RawJsonObject keeps the JSON string itself as the single source of truth: toJson() returns it byte-exact, never re-encoded; the decoded document is only a lazy read-only cache built on first read.
Subclasses map the fields of interest through a protected read API and expose their own typed getters:
use JakubBoucek\Hydrator\Struct\RawJsonObject; class WebhookPayload extends RawJsonObject { public function getEventName(): string { return $this->getString('event'); // strict: missing/null throws } public function getRepositoryName(): ?string { return $this->tryGetString(['repository', 'full_name']); // tolerant: missing/null → null } }
- Read-only by design. Mutating a foreign document would degrade the untouched guarantee from byte level to data level; a future write path is
toArray()→ modify →fromArray()(a new instance, a new string). - Strict and tolerant twins.
getValue()/getString()/getInt()/getFloat()/getBool()/getArray()are non-null and throwValueExceptionon a missing or null field; thetryGet*twins return null for missing-or-null — the$data['a'][0] ?? nullanalogy. Both throw on a present value of the wrong type:getFloataccepts an int (JSON has a single number type),getIntrejects a fraction (truncation would be a silent cast).hasValue()hasarray_key_existssemantics — an explicit JSON null is a present field, the only way to tell missing from null. - Per-call tolerance switches for foreign producers that do not keep type discipline, both default-off.
allowCast: truepermits exactly the conversions where only the JSON representation of the value changes, never the value itself: a string casts iff it is itself a valid JSON literal of the target family —getInt('id', allowCast: true)accepts"123","3.0"and"1e3"but not"0123"or"3.5";getBooladditionally accepts0/1and"true"/"false"/"0"/"1";getStringrenders numbers in canonical JSON form (1.0→'1.0'). Never truthiness, truncation or locale parsing;getArraycasts nothing.mistypeToNull: true(ontryGet*only — the strictget*keeps its non-null promise) returns null instead of throwing on a wrong, uncastable type. Combined, an uncastable value yields null:tryGetInt('id', allowCast: true, mistypeToNull: true). - Nested fields are addressed by an array path:
$this->getInt(['items', 0, 'quantity']). No dot-notation — a foreign key may itself contain a dot. - Object root only.
fromJson()validates the document (json_validate) and requires a{root;fromArray()mirrors that by rejecting lists. - Emptiness is presence of the document, not its content: a
NULLcolumn hydrates into an empty, fully readable instance and renders back asNULL, while a present'{}'is a value and roundtrips verbatim.
Custom types
A custom type maps a domain value object to a single column through an intermediate native type: the value first passes the format codec of that native type — with all its strictness — and only then reaches the custom conversion. Custom types are therefore format-blind: the same type renders as 'Y-m-d H:i:s' in Mysql, RFC 3339 in Json and an instance pass-through with nette/database, and how a bool or a date-time is represented never becomes the custom code's business.
Own types implement one typed sub-interface of the CustomValue marker — the interface choice declares the native type: StringValue, IntValue, FloatValue, BoolValue, DateTimeValue, IntervalValue.
use JakubBoucek\Hydrator\Value\IntValue; final class Money implements IntValue { private function __construct( public readonly int $cents, ) {} public static function fromNative(int $value): static // exact type, no unions { return new static($value); } public function toNative(): ?int { return $this->cents; } }
Foreign classes — types that exist independently and cannot implement the interface (ramsey/uuid and friends) — get a registered TypeAdapter:
use JakubBoucek\Hydrator\Value\NativeType; use JakubBoucek\Hydrator\Adapter\TypeAdapter; final class UuidAdapter implements TypeAdapter { public static function provides(): array { return [ UuidInterface::class => NativeType::String, LazyUuidFromString::class => NativeType::String, ]; } public function import(mixed $value, string $targetClass): object { return Uuid::fromString((string) $value); } public function export(object $value): string { return (string) $value; } } $factory = new HydratorFactory( format: NetteDatabase::class, adapters: [UuidAdapter::class, new GeoAdapter($resolver)], );
Rules of the mechanism:
- Registration order is binding — the first adapter declaring a class wins. Class-strings load lazily: an adapter is instantiated only when a processed entity actually uses a declared class. Instances suit adapters with dependencies and win over a class-string registration of the same class.
provides()is a pure function of the class: exact-match string keys that may reference classes absent from the application (optional dependencies) — matching never triggers autoload and the capability map is plain, cacheable data by contract.- Adapters cannot shadow natively handled classes (
DateTimeImmutable,DateInterval,BackedEnum,StructandCustomValueimplementations) — that fails loudly at metadata build. - Null policy is deliberately asymmetric:
fromNative()/import()never receive null — aNULLfield is decided by the property's nullability — whiletoNative()/export()may return null for inner nullness; the field is then stored asNULLand the value collapses to a plain null on the next hydration.
A native JSON representation: JsonValue
Sometimes the intermediate native type is right for a database but wrong for a JSON payload — a value stored as a compact string in a column should appear as a nested object in an API document, not as that string. A custom value may therefore additionally implement JsonValue: with formats of the JSON family (the JsonFormat marker, implemented by Json) the value then travels through fromJsonValue()/toJsonValue() as a decoded JSON value — string, number, bool or a nested array — and the intermediate native type is bypassed. Everywhere else the CustomValue pair keeps working; custom code stays format-blind, the interface addresses a representation family, never a concrete format (the same way a Struct offers its array pair).
use JakubBoucek\Hydrator\Value\JsonValue; use JakubBoucek\Hydrator\Value\StringValue; final class Point implements StringValue, JsonValue { // fromNative()/toNative(): 'lat;lng' string for the database … public static function fromJsonValue(mixed $value): static { if (!is_array($value) || !is_numeric($value['lat'] ?? null) || !is_numeric($value['lng'] ?? null)) { throw new ValueException('Expected {lat, lng} object, got ' . get_debug_type($value) . '.'); } return new static((float) $value['lat'], (float) $value['lng']); } public function toJsonValue(): mixed { return ['lat' => $this->lat, 'lng' => $this->lng]; } }
- Not standalone: the class must implement a typed
CustomValueinterface as well — that one carries the representation for every other format.JsonValuealone (or combined withStruct) fails loudly at metadata build. - No intermediate codec runs, so no static parameter type is possible:
fromJsonValue()receives the raw decoded value and must validate it itself, throwingValueException— the engine adds property and field context. The value comes from a foreign document where no type is ever guaranteed; that runtime check is the strictness of the boundary. - The null policy carries over:
fromJsonValue()never receives null,toJsonValue()may return it — the field is then null and the value collapses to a plain null on the next hydration. - The
JsonFormatmarker also works as an attribute scope:#[Name('field', [JsonFormat::class])].
Whole-document values: RawJsonValue
Where RawJsonObject maps the fields of interest inside a foreign JSON object, RawJsonValue treats a whole document of any root type — object, array, string, number, bool or null — as one opaque value: it only decodes and encodes, nothing more. It is a custom value (StringValue + JsonValue), not a Struct, and the string is the single source of truth: toNative() returns it byte-exact, never re-encoded, so number representation, escaping, key order and duplicate keys survive a database roundtrip untouched.
class Event implements Entity { public int $id; public ?RawJsonValue $payload; } $event = $hydrator->fromData($row); $event->payload?->getType(); // JsonType::Object | List | String | Number | Bool | Null $event->payload?->isObject(); // sugar: isObject/isList/isString/isNumber/isBool/isNull $event->payload?->getValue(); // lazy-decoded value (arrays for objects), cached $event->payload = RawJsonValue::fromJsonValue(['a' => 1]); // a new document is a new instance
- The two nulls stay apart in the type system. A
NULLcolumn never reaches the class — it is decided by the property's nullability, so$event->payload === nullis the absent column while$event->payload->isNull()is the present'null'document; writing the SQLNULLmeans assigning null to the property. There is no empty state: an instance always holds a valid document (a freshnewstarts with'null'). getType()reads the root type from the string without decoding. It is deliberately JSON-level — a number is one case: whether1e2decodes into an int or a float is a property of the decoded value (is_int()on it), not of the document.- Read-only by design (the
RawJsonObjectprecedent): a different document is a different instance —fromNative()takes a specific serialized form,fromJsonValue()builds the canonical encoding of a PHP value (an unserializable value fails loudly with aValueException; a PHP empty array renders as'[]', a specific representation such as'{}'comes in viafromNative()or an object value). The verbatim guarantee is therefore unconditional. - In the JSON family the document embeds natively (via
JsonValue), not as an escaped string. Two documented losses follow: the embedded value passed a decode/encode roundtrip (byte-exactness holds on the database path), and a'null'document renders as a null field — a nested position has no way to carry the distinction, so it collapses to a plain null on rehydration.
Tests
composer install
composer run test
composer run phpstan
Integration tests against a real MariaDB server (common column types over PDO, mysqli and nette/database in every convertBoolean/newDateTime configuration) run when DATABASE_DSN (plus optional DATABASE_USER/DATABASE_PASSWORD) points to a server, and skip otherwise.
License
MIT. See the LICENSE file.