Skip to content

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

bash
composer require jardissupport/data

GitHub: jardisSupport/data

No optional extensions required, the package works with pure PHP.

Hydration

Filling an Entity

php
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 typeDB valuePHP 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_namefirstName, created_atcreatedAt.

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:

php
// 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 change

hydrate() 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:

php
$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:

php
$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 2

The 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

php
// 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

php
// 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

php
$rows = $pdo->query('SELECT * FROM users')->fetchAll(PDO::FETCH_ASSOC);
$users = $hydration->loadMultiple(new User(), $rows);
// Array of hydrated user objects with individual snapshots

Identity

Four ID strategies without external dependencies:

php
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 ID

UUID 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:

php
$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:

php
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:

php
$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.

AttributeTargetParameters
#[Table]Classname, schema
#[Aggregate]Classname, root
#[Column]Propertyname, type, length, precision, scale, nullable, default, unique
#[PrimaryKey]PropertyautoIncrement
#[ForeignKey]PropertyreferencedTable, referencedColumn, onUpdate, onDelete
#[Relation]Propertytype ('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_case

Directory 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.php

API Reference

Hydration

MethodSignatureDescription
hydratehydrate(object $entity, array $data): objectFill + update snapshot
applyapply(object $entity, array $data): objectFill without snapshot update
hydrateAggregatehydrateAggregate(object $aggregate, array $data): objectRecursive graph hydration
getChangesgetChanges(object $entity): arrayChanged columns + new values
getChangedFieldsgetChangedFields(object $entity): arrayChanged column names only
getSnapshotgetSnapshot(object $entity): arrayCurrent snapshot
cloneclone(object $entity): objectShallow clone
cloneAggregatecloneAggregate(object $entity): objectDeep clone
diffdiff(object $a, object $b): arrayDifferences between two entities
toArraytoArray(object $entity): arrayEntity → flat array
aggregateToArrayaggregateToArray(object $entity): arrayAggregate → nested array
loadMultipleloadMultiple(object $template, array $rows): arrayBatch hydration

Identity

MethodSignatureDescription
generateUuid4generateUuid4(): stringRandom UUID v4
generateUuid5generateUuid5(string $namespace, string $name): stringDeterministic UUID v5
generateUuid7generateUuid7(): stringTime-based UUID v7
generateNanoIdgenerateNanoId(int $length = 21, string $alphabet = '...'): stringCompact random ID

FieldMapper

MethodSignatureDescription
toColumnstoColumns(array $data, array $map): arrayDomain → DB keys
fromColumnsfromColumns(array $data, array $map): arrayDB → domain keys (recursive)
fromAggregatefromAggregate(array $data, callable $mapProvider, string $entityName): arrayAggregate mapping

Complete Example

A repository use case with hydration, change tracking and identity:

php
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);