Data
Entity hydration, change tracking and identity generation, the bridge between database and domain.
Introduction
Between a database row and a PHP object lies a lot of work: translating column names to property names, casting strings to int, bool or DateTimeImmutable, detecting changes, generating UUIDs. Most solutions for this are called Doctrine, and bring an entire ORM with them.
jardissupport/data solves exactly these problems without being an ORM. Three focused orchestrators that can be used independently of each other:
- Hydration — fills PHP objects from database arrays, including nested aggregates with arbitrary depth. Automatic type casting, setter respecting, and a snapshot mechanism for change tracking
- Identity — UUID v4, v5, v7 and NanoID without external dependencies. UUID v7 with monotonic counter for collision-safe batch inserts
- FieldMapper — bidirectional key translation between domain (
customerName) and database (customer_name)
Installation
composer require jardissupport/dataGitHub: jardisSupport/data
No optional extensions required, the package works with pure PHP.
Hydration
Filling an Entity
use JardisSupport\Data\Hydration;
$hydration = new Hydration();
$user = new User();
$hydration->hydrate($user, [
'id' => 1,
'first_name' => 'John',
'email' => 'john@example.com',
'age' => '30', // → int 30
'active' => '1', // → bool true
'created_at' => '2025-01-15 10:00:00', // → DateTimeImmutable
]);Automatic type casting based on the property type:
| Property type | DB value | PHP value |
|---|---|---|
int | '42' | 42 |
float | '9.99' | 9.99 |
bool | '1' / '0' | true / false |
DateTime | '2025-01-15 10:00:00' | DateTime object |
DateTimeImmutable | '2025-01-15' | DateTimeImmutable object |
BackedEnum | 'ELECTRICITY' | GatewayType::Electricity |
Column name → property name is automatically converted: first_name → firstName, created_at → createdAt.
Setters are respected: if a setFirstName() method exists, it is called instead of setting the property directly.
hydrate() vs. apply()
Two methods, one crucial difference:
// hydrate(): Sets properties AND updates the snapshot
$hydration->hydrate($entity, $dbRow);
$hydration->getChanges($entity); // [] — no changes
// apply(): Sets properties, leaves the snapshot unchanged
$hydration->apply($entity, ['name' => 'Jane']);
$hydration->getChanges($entity); // ['name' => 'Jane'] — detected as changehydrate() for data from the database. apply() for programmatic changes that should be detected as a diff.
Change Tracking
Every hydration creates a snapshot: an array of the original values. Changes are detected by comparing against the snapshot:
$hydration->hydrate($user, ['id' => 1, 'name' => 'John', 'email' => 'john@example.com']);
$user->setName('Jane');
$user->setEmail('jane@example.com');
$hydration->getChanges($user);
// ['name' => 'Jane', 'email' => 'jane@example.com']
$hydration->getChangedFields($user);
// ['name', 'email']
$hydration->getSnapshot($user);
// ['id' => 1, 'name' => 'John', 'email' => 'john@example.com']Snapshot values are always scalars: DateTime is stored as 'Y-m-d H:i:s', BackedEnum as $enum->value. This prevents false positives from object identity comparisons.
Snapshot Property
Entities require a private array $__snapshot = [] property. The snapshot is written via reflection. Optional: a getSnapshot(): array method as a performance fast-path.
Aggregate Hydration
Nested object graphs (a root entity with ONE and MANY relations) are hydrated recursively:
$order = new Order();
$hydration->hydrateAggregate($order, [
'id' => 1,
'customer_id' => 42,
'total' => '299.99',
// ONE relation: associative array → new object
'shipping_address' => [
'id' => 10,
'street' => 'Hauptstraße 1',
'city' => 'Berlin',
'country' => [
'id' => 'DE',
'name' => 'Germany',
],
],
// MANY relation: array of associative arrays → collection
'order_lines' => [
['id' => 100, 'product_id' => 'P-001', 'quantity' => 2, 'price' => '49.99'],
['id' => 101, 'product_id' => 'P-002', 'quantity' => 1, 'price' => '199.99'],
],
]);
$order->getShippingAddress()->getCountry()->getName(); // 'Germany'
$order->getOrderLines()[0]->getQuantity(); // int 2The detection of ONE vs. MANY happens automatically based on the data structure:
- Associative array + property type is a class → ONE relation
- Indexed array of associative arrays → MANY relation
Collection elements are typed via an add{SingularName}() method or @var SomeClass[] docblock.
Each level gets its own snapshot, only DB columns, no relation keys.
Entity to Array
// Flat — DB columns only
$hydration->toArray($user);
// ['id' => 1, 'name' => 'Jane', 'email' => 'jane@example.com', 'created_at' => '2025-01-15 10:00:00']
// Nested — entire aggregate graph
$hydration->aggregateToArray($order);
// ['id' => 1, 'customer_id' => 42, 'shipping_address' => ['id' => 10, ...], 'order_lines' => [...]]Clone and Diff
// Shallow clone — DB column properties only
$clone = $hydration->clone($user);
// Deep clone — entire aggregate graph, DateTime objects cloned
$clonedOrder = $hydration->cloneAggregate($order);
// Diff — compares two entities of the same class
$user1->setName('John');
$user2->setName('Jane');
$hydration->diff($user1, $user2); // ['name' => 'Jane']Batch Hydration
$rows = $pdo->query('SELECT * FROM users')->fetchAll(PDO::FETCH_ASSOC);
$users = $hydration->loadMultiple(new User(), $rows);
// Array of hydrated user objects with individual snapshotsIdentity
Four ID strategies without external dependencies:
use JardisSupport\Data\Identity;
$identity = new Identity();
// UUID v4 — random
$identity->generateUuid4();
// 'a3f8b2c1-4d5e-4f6a-8b7c-9d0e1f2a3b4c'
// UUID v7 — time-based + monotonic counter
$identity->generateUuid7();
// '01912345-6789-7abc-8def-0123456789ab'
// UUID v5 — deterministic (SHA-1)
$identity->generateUuid5($namespaceUuid, 'customer:12345');
// Same input → always same UUID
// NanoID — compact, URL-safe
$identity->generateNanoId(); // 21 characters, ~126 bit entropy
$identity->generateNanoId(length: 10); // Shorter IDUUID v7 — Collision-safe in Batches
UUID v7 is time-based and sortable. The monotonic counter guarantees unique, ascending IDs even with thousands of inserts per millisecond:
- Bytes 0–5: 48-bit Unix millisecond timestamp
- Bytes 6–7: 12-bit monotonic counter (per-millisecond start value random in range 0–31)
- Bytes 8–15: 62-bit random
On counter overflow (> 4095 per ms), the generator waits for the next millisecond.
UUID v5 — Deterministic IDs
Same namespace + same name → always the same UUID. Ideal for stable IDs from business keys:
$namespace = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; // DNS namespace
$identity->generateUuid5($namespace, 'customer:12345');
// Always: '...' (deterministic)NanoID — Compact and URL-safe
Uses bitmask rejection sampling on random_bytes for uniform distribution. Default alphabet: 64 characters (_-0-9a-zA-Z), default length 21 (~126 bit entropy). Both configurable per call.
FieldMapper
Bidirectional key translation between domain model and database:
use JardisSupport\Data\FieldMapper;
$mapper = new FieldMapper();
$map = [
'customerName' => 'name',
'orderNumber' => 'order_number',
'postalCode' => 'postal_code',
];
// Domain → DB
$mapper->toColumns(['customerName' => 'John', 'postalCode' => '10115'], $map);
// ['name' => 'John', 'postal_code' => '10115']
// DB → Domain (recursive for nested arrays)
$mapper->fromColumns(['name' => 'John', 'postal_code' => '10115'], $map);
// ['customerName' => 'John', 'postalCode' => '10115']Aggregate Mapping
For nested structures with per-entity mappings:
$mapProvider = fn(string $entity) => match ($entity) {
'order' => ['orderNumber' => 'order_number', 'status' => 'status'],
'customer' => ['customerName' => 'name', 'email' => 'email'],
'item' => ['productId' => 'product_identifier', 'quantity' => 'quantity'],
};
$result = $mapper->fromAggregate($aggregateArray, $mapProvider, 'order');Behavior with unmapped keys
toColumns() and fromColumns() pass unmapped keys through unchanged. fromAggregate() filters unmapped keys: only explicitly mapped fields leave the repository layer.
PHP Attributes
The package defines attributes for metadata primarily used by the builder tooling. The hydration itself requires no attributes. It works value-based.
| Attribute | Target | Parameters |
|---|---|---|
#[Table] | Class | name, schema |
#[Aggregate] | Class | name, root |
#[Column] | Property | name, type, length, precision, scale, nullable, default, unique |
#[PrimaryKey] | Property | autoIncrement |
#[ForeignKey] | Property | referencedTable, referencedColumn, onUpdate, onDelete |
#[Relation] | Property | type ('one'/'many'), target |
Architecture
Three independent orchestrators, each with their own handlers in the Closure-Orchestrator-Pattern:
Hydration ← Orchestrator
├── HydrateEntity ← Flat hydration + snapshot
├── HydrateAggregate ← Recursive graph hydration
├── DetectChanges ← Snapshot diff
├── SetSnapshot / GetSnapshot ← Snapshot access (reflection)
├── SetPropertyValue / GetPropertyValue ← Setter/getter resolution
├── TypeCaster ← DB → PHP type casting
├── EntityToArray / AggregateToArray ← Entity → array
├── CloneEntity / CloneAggregate ← Flat/deep clone
├── DiffEntities ← Entity comparison
└── LoadMultiple ← Batch hydration
Identity ← Orchestrator
├── GenerateUuid4 ← Random UUID
├── GenerateUuid5 ← Deterministic (SHA-1)
├── GenerateUuid7 ← Time-based + counter
└── GenerateNanoId ← Compact, URL-safe
FieldMapper ← Orchestrator
├── ColumnNameToPropertyName ← snake_case → camelCase
└── PropertyNameToColumnName ← camelCase → snake_caseDirectory Structure
src/
├── Hydration.php ← Orchestrator
├── Identity.php ← Orchestrator
├── FieldMapper.php ← Orchestrator
├── Attribute/
│ ├── Aggregate.php
│ ├── Column.php
│ ├── ForeignKey.php
│ ├── PrimaryKey.php
│ ├── Relation.php
│ └── Table.php
└── Handler/
├── HydrateEntity.php
├── HydrateAggregate.php
├── DetectChanges.php
├── SetSnapshot.php
├── GetSnapshot.php
├── ToSnapshotValue.php
├── SetPropertyValue.php
├── GetPropertyValue.php
├── ColumnNameToPropertyName.php
├── PropertyNameToColumnName.php
├── TypeCaster.php
├── EntityToArray.php
├── AggregateToArray.php
├── CloneEntity.php
├── CloneAggregate.php
├── DiffEntities.php
├── LoadMultiple.php
├── GenerateUuid4.php
├── GenerateUuid5.php
├── GenerateUuid7.php
└── GenerateNanoId.phpAPI Reference
Hydration
| Method | Signature | Description |
|---|---|---|
hydrate | hydrate(object $entity, array $data): object | Fill + update snapshot |
apply | apply(object $entity, array $data): object | Fill without snapshot update |
hydrateAggregate | hydrateAggregate(object $aggregate, array $data): object | Recursive graph hydration |
getChanges | getChanges(object $entity): array | Changed columns + new values |
getChangedFields | getChangedFields(object $entity): array | Changed column names only |
getSnapshot | getSnapshot(object $entity): array | Current snapshot |
clone | clone(object $entity): object | Shallow clone |
cloneAggregate | cloneAggregate(object $entity): object | Deep clone |
diff | diff(object $a, object $b): array | Differences between two entities |
toArray | toArray(object $entity): array | Entity → flat array |
aggregateToArray | aggregateToArray(object $entity): array | Aggregate → nested array |
loadMultiple | loadMultiple(object $template, array $rows): array | Batch hydration |
Identity
| Method | Signature | Description |
|---|---|---|
generateUuid4 | generateUuid4(): string | Random UUID v4 |
generateUuid5 | generateUuid5(string $namespace, string $name): string | Deterministic UUID v5 |
generateUuid7 | generateUuid7(): string | Time-based UUID v7 |
generateNanoId | generateNanoId(int $length = 21, string $alphabet = '...'): string | Compact random ID |
FieldMapper
| Method | Signature | Description |
|---|---|---|
toColumns | toColumns(array $data, array $map): array | Domain → DB keys |
fromColumns | fromColumns(array $data, array $map): array | DB → domain keys (recursive) |
fromAggregate | fromAggregate(array $data, callable $mapProvider, string $entityName): array | Aggregate mapping |
Complete Example
A repository use case with hydration, change tracking and identity:
use JardisSupport\Data\Hydration;
use JardisSupport\Data\Identity;
use JardisSupport\Data\FieldMapper;
$hydration = new Hydration();
$identity = new Identity();
$mapper = new FieldMapper();
// Create new entity with UUID v7
$order = new Order();
$order->setId($identity->generateUuid7());
// Load and hydrate from DB (including relations)
$hydration->hydrateAggregate($order, [
'id' => $order->getId(),
'customer_id' => 42,
'total' => '299.99',
'status' => 'pending',
'order_lines' => [
['id' => 100, 'product_id' => 'P-001', 'quantity' => 2, 'price' => '49.99'],
['id' => 101, 'product_id' => 'P-002', 'quantity' => 1, 'price' => '199.99'],
],
]);
// Make changes
$order->setTotal(349.99);
$order->setStatus('confirmed');
// Change tracking — only changed fields for the UPDATE
$changes = $hydration->getChanges($order);
// ['total' => 349.99, 'status' => 'confirmed']
// For the UPDATE: domain keys → DB columns
$map = ['total' => 'total_amount', 'status' => 'order_status'];
$dbValues = $mapper->toColumns($changes, $map);
// ['total_amount' => 349.99, 'order_status' => 'confirmed']
// Backup for comparison
$backup = $hydration->clone($order);
$order->setTotal(399.99);
$hydration->diff($backup, $order); // ['total' => 399.99]
// Batch hydration
$rows = $pdo->query('SELECT * FROM order_lines WHERE order_id = ?')->fetchAll();
$lines = $hydration->loadMultiple(new OrderLine(), $rows);