better-data: Hydration and coercion
SkillAI & modelsModify how raw values become typed property values in
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the better-data: Hydration and coercion skill
What this skill tells your AI
The instructions your AI receives, as published by lonsdale201/wp-agent-skills in better-data/bd-hydration-coercion/SKILL.md and read by ahel’s review.
For library maintainers fixing or extending how stored / incoming values become typed property values on a DataObject. The coercion layer sits between the source's raw fetch and the constructor's typed parameters; modifying it touches every DTO that goes through ::fromArray.
Misconception this skill corrects
"I'll just
settype($value, 'int')or(int) $valueinside the hydrator — same effect."
Wrong. PHP's silent casts paper over invalid input — (int) 'abc' === 0, (int) '12foo' === 12, (bool) 'false' === true. better-data's coercion is intentionally strict: surprising input becomes TypeCoercionException with the field name, expected type, and offending value. Verified at src/Internal/TypeCoercer.php:46-58 and the per-helper throws (toString:197, toInt:218, toFloat:235, toBool:254).
The discipline is:
// WRONG inside coercion code
$intValue = (int) $value;
// RIGHT
$intValue = TypeCoercer::toInt($dtoClass, $fieldName, $value);
// throws TypeCoercionException if $value isn't a coercible int — caller gets the field
// name and value in the message instead of silently storing 0.
Other AI-prone misconceptions:
- "I'll add a WP function call inside
TypeCoercer— it makes the code shorter." Wrong —TypeCoerceris the one engine that MUST stay WP-free so its tests can run without a WP runtime. WP-aware logic goes inDataObject::coerceParameteror in the source. - "I'll add the
Encrypteddecryption toTypeCoercer." Wrong layer — attribute-aware coercion lives ABOVETypeCoercerinDataObject::coerceParameter(src/DataObject.php:168). The pattern is: handle the attribute (decrypt, walk the list), then callTypeCoercer::coercewith the simpler value.
When to use this skill
Trigger when ANY of the following is true:
- A bug report says "fromArray hydrates with the wrong type" or "casting issue".
- The diff modifies
src/Internal/TypeCoercer.phporsrc/DataObject.php::coerceParameter. - Adding support for a new primitive type or a new attribute that affects coercion.
- Reviewing a PR that calls
settype(),intval(),(int), or(string)inside coercion code. - Hitting
TypeCoercionExceptionat runtime and triaging.
Workflow
1. Choose the layer
| Change type | Layer |
|---|---|
| New primitive (decimal type, IPv4 stored as string <-> int) | TypeCoercer (pure) |
New WP-builtin handling (e.g. coerce WP_Term to a term ID) | TypeCoercer (still pure — WP_Term is just a class shape; check instanceof doesn't require WP runtime) |
New attribute affects coercion (#[Slug] lowercase before string-coerce) | DataObject::coerceParameter (above TypeCoercer) |
| New attribute affects encryption / list coercion | DataObject::coerceParameter |
The acid test: "Can my code run inside a unit test that does NOT bootstrap WordPress?" If yes, it can live in TypeCoercer. If no (calls wp_remote_get, reads $wpdb, looks up WP_User), it must live elsewhere.
2. Adding a primitive coercion
Inside TypeCoercer::coerce (src/Internal/TypeCoercer.php:83-88):
return match ($targetTypeName) {
'string' => self::toString(...),
'int' => self::toInt(...),
'float' => self::toFloat(...),
'bool' => self::toBool(...),
'array' => self::toArray(...),
// your new branch:
'decimal' => self::toDecimal($dataObjectClass, $fieldName, $value),
default => throw TypeCoercionException::unsupportedType(...),
};
The helper:
private static function toDecimal(string $cls, string $field, mixed $value): Decimal
{
if ($value instanceof Decimal) {
return $value;
}
if (is_string($value) && \preg_match('/^-?\d+(\.\d+)?$/', $value)) {
return new Decimal($value);
}
if (is_int($value) || is_float($value)) {
return new Decimal((string) $value);
}
throw TypeCoercionException::for($cls, $field, 'decimal', $value);
}
Three rules:
- Accept the type-as-input shortcut.
$value instanceof Decimalreturns it unchanged — caller passes back what they got. - Convert from common neighbors. Decimal accepts strings, ints, floats; rejects arrays, booleans, objects of other types.
- Throw
TypeCoercionException::for(...)with class + field + target + offending value when nothing matches.
3. Adding an attribute-aware coercion
Inside DataObject::coerceParameter (src/DataObject.php:168-220) BEFORE the TypeCoercer::coerce final delegation:
private static function coerceParameter(ReflectionParameter $parameter, mixed $value): mixed
{
// Existing #[Encrypted] decryption (idempotent envelope check) — see lines 173-184.
// Your new attribute-aware coercion — example: #[Slug] lowercases before string coerce.
$slugAttr = $parameter->getAttributes(Slug::class)[0] ?? null;
if ($slugAttr !== null && is_string($value)) {
$value = \mb_strtolower($value);
// Don't return here — let TypeCoercer handle the final string coercion below
// so length / max-length attribute can also apply.
}
// Existing #[ListOf] handling (lines 185-208).
// Final fallback to pure TypeCoercer.
return TypeCoercer::coerce(
static::class,
$parameter->getName(),
$parameter->getType(),
$value,
);
}
Pattern: read the attribute → mutate $value (or recurse, or replace) → fall through to TypeCoercer for the final type cast. Don't duplicate TypeCoercer's logic above it.
4. Idempotency for read-side transformations
#[Encrypted] is the canonical example of an idempotent transformation (DataObject.php:173-184):
if (is_string($value)
&& $value !== ''
&& EncryptionEngine::looksEncrypted($value)
&& $parameter->getAttributes(Encrypted::class) !== []
) {
$value = EncryptionEngine::decrypt($value, $parameter->getName());
}
Three checks: is it a non-empty string, does it look like a bd:v1: envelope, does the property carry #[Encrypted]. If any check fails, the transformation no-ops — so a freshly-decrypted value passing through this path again doesn't double-decrypt. Apply the same idempotency principle to your transformation.
5. Tests
Each coercion path needs unit tests:
- Primitive coercions →
tests/Unit/TypeCoercionTest.php. Cover the type itself, neighbor types (int from numeric string, etc.), and rejection (array → int throws). - Attribute-aware coercions → their own file, e.g.
tests/Unit/SlugAttributeTest.php,tests/Unit/ListOfTest.php,tests/Unit/EncryptedAttributeTest.php,tests/Unit/SecretTest.php.
Two specific shapes per coercion:
public function test_it_coerces_a_valid_input(): void
{
$dto = MyDto::fromArray(['decimalField' => '1.50']);
$this->assertInstanceOf(Decimal::class, $dto->decimalField);
$this->assertSame('1.50', (string) $dto->decimalField);
}
public function test_it_throws_on_invalid_input(): void
{
$this->expectException(TypeCoercionException::class);
MyDto::fromArray(['decimalField' => 'abc']);
}
6. Run the full check
vendor/bin/phpunit
vendor/bin/phpstan analyse --memory-limit=1G
vendor/bin/php-cs-fixer fix
wp better-data stress # if the change can affect WP-side hydration
Critical rules
TypeCoercerstays pure. No WP function calls, no$_*superglobals, no globals, no constants. Must be unit-testable without WP bootstrap.- Attribute-aware coercion lives in
DataObject::coerceParameter. Read attribute → transform value → optionally fall through toTypeCoercer. - Use the explicit helpers (
toString,toInt,toFloat,toBool,toArray). Neversettype(),intval(),(int)cast on unchecked input — those silently turn invalid data into 0/false. - Throw
TypeCoercionExceptionon anything surprising. Caller gets class + field + expected type + offending value in the message. - Idempotency for read-side transformations. A value that's already been transformed (decrypted, lowercased, parsed) should pass through unchanged on the next call. Use a "looks like the post-transform shape?" check.
- Accept the type-as-input shortcut. If a coercion target is
Decimal,Decimal $value === $valueshort-circuits. - Single-attribute change goes in ONE PR with all relevant engines wired. A new attribute that affects coercion also affects
RestSchemaBuilder, sink projection, etc. — don't ship partial.
Common mistakes
// WRONG — settype inside coercion
private static function toInt(string $cls, string $field, mixed $value): int
{
\settype($value, 'integer'); // 'abc' silently becomes 0
return $value;
}
// RIGHT — explicit checks + throw on bad input
private static function toInt(string $cls, string $field, mixed $value): int
{
if (\is_int($value)) {
return $value;
}
if (\is_string($value) && \preg_match('/^-?\d+$/', $value)) {
return (int) $value;
}
if (\is_float($value) && \floor($value) === $value) {
return (int) $value;
}
throw TypeCoercionException::for($cls, $field, 'int', $value);
}
// WRONG — WP function call in TypeCoercer
private static function toUserId(string $cls, string $field, mixed $value): int
{
if (\is_string($value)) {
return (int) \get_user_by('login', $value)?->ID; // WRONG: not WP-free
}
return self::toInt($cls, $field, $value);
}
// RIGHT — keep WP-aware logic in src/Source/ where it belongs
// WRONG — duplicating TypeCoercer logic in coerceParameter
private static function coerceParameter(ReflectionParameter $parameter, mixed $value): mixed
{
$type = $parameter->getType()->getName();
if ($type === 'int') {
return (int) $value; // WRONG: reimplements toInt, loses the validation
}
// ...
}
// RIGHT — let TypeCoercer handle primitive types after attribute logic
return TypeCoercer::coerce(
static::class,
$parameter->getName(),
$parameter->getType(),
$value,
);
// WRONG — non-idempotent read-side transformation
if ($parameter->getAttributes(Encrypted::class) !== []) {
$value = EncryptionEngine::decrypt($value, $parameter->getName());
}
// Crash on the second pass: trying to decrypt already-plaintext value.
// RIGHT — idempotent guard
if (\is_string($value)
&& $value !== ''
&& EncryptionEngine::looksEncrypted($value)
&& $parameter->getAttributes(Encrypted::class) !== []
) {
$value = EncryptionEngine::decrypt($value, $parameter->getName());
}
// WRONG — silent fallback on unknown type
'unknown_type' => $value, // pass through unchanged
// Hides bugs — caller expected a specific shape, gets a mystery value.
// RIGHT
'unknown_type' => throw TypeCoercionException::unsupportedType(...),
Cross-references
- Run
bd-attributewhen adding a new attribute that affects coercion — wire it throughDataObject::coerceParameterAND every other engine. - Run
bd-data-objectif hydration changes affect specific DTO patterns — sometimes the right fix is the DTO design, not the coercer. - Run
bd-securitywhen the coercion touchesSecretor#[Encrypted]— symmetric encrypt/decrypt is mandatory.
What this skill does NOT cover
- Replacing
TypeCoercerwith a different coercion library (Symfony Serializer, etc.). The library deliberately keeps its own minimal coercion to stay framework-free. - Async / lazy hydration. All coercion is sync; lazy fields are a Presenter concern (
compute). - Data validation. Coercion turns a value into the right TYPE; validation (
Rule\*) checks if the typed value passes business rules. They run sequentially, never together. - Performance optimization beyond reflection caching (which the library doesn't do —
coerceParameterreads attributes per call). Premature. - Coercion FROM DTO TO storage. That's
SinkProjection, not coercion.
References
- TypeCoercer: libraries/better-data/src/Internal/TypeCoercer.php —
final class TypeCoercer,coerce()at line 25,toStringat 179,toIntat 200,toFloatat 221,toBoolat 238,toArrayfurther below,toEnumfor backed enums. - Attribute-aware layer: libraries/better-data/src/DataObject.php:168-220 —
coerceParameter. Encryption check at 173-184, ListOf at 185-208, TypeCoercer fallback at 215-220. - AttributeDrivenHydrator: libraries/better-data/src/Internal/AttributeDrivenHydrator.php — the WP-side counterpart that uses fetcher closures and applies attribute-driven decryption / list coercion.
TypeCoercionException: libraries/better-data/src/Exception/TypeCoercionException.php —for($cls, $field, $target, $value),unsupportedType($cls, $field, $target).- Encryption envelope detection:
EncryptionEngine::looksEncrypted— string predicate that doesn't decrypt; safe to call on any string. - Official documentation: https://github.com/lonsdale201/better-data
- Verified source paths:
src/Attribute/Encrypted.phpsrc/Attribute/ListOf.phpsrc/Encryption/EncryptionEngine.php
Signals
- GitHub stars
- 22
- Forks
- 2
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
bd-hydration-coercion- Source
- github.com/lonsdale201/wp-agent-skills