jakubboucek/hydrator

Fast bidirectional hydrator between typed PHP entities and database rows or other data formats, built for modern PHP with property hooks support

Maintainers

Package info

github.com/jakubboucek/hydrator

pkg:composer/jakubboucek/hydrator

Transparency log

Statistics

Installs: 13

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.7.0 2026-08-23 07:27 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)/readonly properties 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() and getInitializedPropertyNames() let application code ask what a partial entity carries.
  • Pass-through of already-typed values — layers like nette/database return DateTimeImmutable, bool and DateInterval instances; the hydrator accepts them as-is instead of demanding strings.
  • Database type nuancesDATE vs DATETIME (#[Type\Date]) and TIME columns (day-scoped #[Type\Time] or full-range DateInterval) 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: true tolerates missing fields — the corresponding properties stay uninitialized, mirroring the partial extraction: a sparse SELECT id, title hydrates a sparse entity whose toData() produces exactly those fields back. Combined with into: 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; pair allowPartial with rejectUnknown to keep that protection.
  • rejectUnknown: true tightens 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 semanticsisset() conflates a stored null ("set the column to NULL") with uninitialized ("don't touch it"); isInitialized() keeps them apart.
  • Entities with get hooksisset() 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 questionsgetInitializationState() classifies the whole entity as Empty, Partial or Complete, e.g. to skip work on an empty patch. The state is relative to the entity's own declaration, nothing more: Complete promises neither "ready to INSERT" nor "safe to read", and an entity with no mapped properties reports Empty.

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 as 0/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:

  • DateInterval property — full MySQL TIME compatibility: sign and hours over 24 ('-838:59:59''838:59:59'), values a DateTime cannot hold. MySQL documents TIME as dual-purpose — time of day and elapsed time — and this mapping carries all of it.
  • #[Type\Time] on DateTimeImmutable — 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 NULL column 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 verbatim RawJsonObject treats 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 (never JSON_FORCE_OBJECT, which would mangle them too). The PHP side is deliberately tolerant: $value is 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 $value stores as NULL, a NULL column hydrates into an empty writable instance. For a NOT NULL column, 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 a ValueException at extraction into a serialized representation (the database formats), wrapped with property context by the engine. The Json format's payload is decoded data — the hydrator never encodes there, so such a value passes through toData() (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 (1e5100000.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 throw ValueException on a missing or null field; the tryGet* twins return null for missing-or-null — the $data['a'][0] ?? null analogy. Both throw on a present value of the wrong type: getFloat accepts an int (JSON has a single number type), getInt rejects a fraction (truncation would be a silent cast). hasValue() has array_key_exists semantics — 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: true permits 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"; getBool additionally accepts 0/1 and "true"/"false"/"0"/"1"; getString renders numbers in canonical JSON form (1.0'1.0'). Never truthiness, truncation or locale parsing; getArray casts nothing. mistypeToNull: true (on tryGet* only — the strict get* 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 NULL column hydrates into an empty, fully readable instance and renders back as NULL, 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, Struct and CustomValue implementations) — that fails loudly at metadata build.
  • Null policy is deliberately asymmetric: fromNative()/import() never receive null — a NULL field is decided by the property's nullability — while toNative()/export() may return null for inner nullness; the field is then stored as NULL and 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 CustomValue interface as well — that one carries the representation for every other format. JsonValue alone (or combined with Struct) 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, throwing ValueException — 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 JsonFormat marker 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 NULL column never reaches the class — it is decided by the property's nullability, so $event->payload === null is the absent column while $event->payload->isNull() is the present 'null' document; writing the SQL NULL means assigning null to the property. There is no empty state: an instance always holds a valid document (a fresh new starts with 'null').
  • getType() reads the root type from the string without decoding. It is deliberately JSON-level — a number is one case: whether 1e2 decodes 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 RawJsonObject precedent): 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 a ValueException; a PHP empty array renders as '[]', a specific representation such as '{}' comes in via fromNative() 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.