better-data: Adding a DataObject
For library maintainers and downstream contributors who add or modify a DataObject subclass inside better-data. Every typed shape — production DTOs in src/, test fixtures in tests/Fixtures/, plugin-level DTOs in the companion testbed — extends the abstract DataObject (src/DataObject.php:36) and is the foundation that every engine (sources, sinks, validation, Presenter, REST schema, better-route bridge) reads against.
Misconception this skill corrects
"I'll just declare the constructor parameters with the types I want and set the values when I instantiate the class — defaults are optional."
In better-data, defaults are load-bearing. The hydration entry point DataObject::fromArray (src/DataObject.php:47-79) iterates ReflectionParameters and treats any parameter without isDefaultValueAvailable() (and without allowsNull()) as REQUIRED — throwing MissingRequiredFieldException. PHP's Reflection silently demotes earlier-positioned defaults to "required" if a later parameter has no default — so a single missing default at the end cascades and breaks ::fromArray for the whole DTO.
Other AI-prone misconceptions:
- "I'll add
encrypt: true to the MetaKey and store the property as a plain string." Wrong shape — #[Encrypted] writes ciphertext but the in-memory value is still a plain string that leaks via var_dump / print_r / serialize. Use Secret as the property type.
- "I'll add a public mutator method (
setEmail()) to make consumer code more ergonomic." Wrong — every DTO is final readonly class. Mutation is $dto->with(['email' => 'new@example.com']) which returns a NEW instance. Mutators break the immutability contract that Secret, route-side projection, and Presenter caching all depend on.
When to use this skill
Trigger when ANY of the following is true:
- Adding a new
final readonly class extends DataObject under src/, tests/Fixtures/, or the companion plugin's Dto/.
- Adding, removing, or retyping a constructor parameter on an existing DTO.
- The diff or PR title mentions: "new DTO", "add Dto", "add field to Dto", "introduce Dto".
- Reviewing a class that extends
DataObject — use this skill's checklist before approving.
- Hitting
MissingRequiredFieldException at runtime — usually the cause is a trailing parameter without a default.
Workflow
1. Choose the file location
| What you're building |
Path |
| Production DTO (library users hydrate it) |
src/<area>/<Name>Dto.php |
| Test-only fixture |
tests/Fixtures/<Name>Dto.php |
| Plugin-level DTO (companion testbed) |
wp-content/plugins/better-data-plugin-test/src/Dto/<Name>Dto.php |
2. Declare the class
namespace MyNamespace;
use BetterData\DataObject;
use BetterData\Source\HasWpSources;
use BetterData\Sink\HasWpSinks;
use BetterData\Presenter\HasPresenter;
final readonly class ProductDto extends DataObject
{
use HasWpSources;
use HasWpSinks;
use HasPresenter;
public function __construct(
public int $id = 0,
public string $post_title = '',
public string $post_status = 'publish',
) {}
}
Three non-negotiables:
final — never extended. The library does not support subclass-of-DTO patterns.
readonly — every property is immutable. Hydration writes once via newInstanceArgs; consumers mutate via ->with(...).
extends DataObject — gives you ::fromArray, ::fromArrayValidated, ->toArray(), ->with(), attribute-aware coercion.
3. Constructor-promoted parameters with defaults on every trailing one
The single most important rule. Every parameter must have either an explicit default OR be nullable. Recommended defaults by type:
| Type |
Default |
int |
= 0 |
string |
= '' |
float |
= 0.0 |
bool |
= false |
array |
= [] |
?DateTimeImmutable |
= null |
?Secret |
= null (NEVER new Secret('')) |
BackedEnum |
first case (= MyEnum::Default) or = null if nullable |
PHP-side reasoning: ReflectionParameter::isDefaultValueAvailable() returns false when ANY required parameter sits later in the signature. The hydrator at src/DataObject.php:63-73 checks this exact predicate; missing a default at position N silently breaks defaults at positions 0..N-1.
4. Choose the most-specific type possible
Better-data leans on type information for coercion, schema generation, and Presenter formatting. Be specific:
?DateTimeImmutable over ?string for timestamps — TypeCoercer parses ISO-8601 strings and WC_DateTime instances automatically.
Secret over string for credentials — provides redacted __toString, throwing __serialize, leak-probe-tested behaviour.
BackedEnum subclass over string for closed sets — TypeCoercer::toEnum resolves the value or throws TypeCoercionException.
- A specific
DataObject subclass over array for nested structures — coercion delegates to $class::fromArray() recursively (src/DataObject.php:195-197).
5. Decorate with attributes
Each attribute is a pure data carrier (src/Attribute/) read by one or more engines:
| Attribute |
Read by |
Purpose |
#[MetaKey('key', type: 'number', showInRest: true)] |
OptionSink, PostSink::toMeta, RestSchemaBuilder |
Map property to a meta_key and REST schema |
#[PostField('post_date_gmt')] |
PostSink, PostSource |
Rename DTO param to a wp_posts column |
#[UserField], #[TermField], #[Column] |
corresponding sink/source |
Same but for users / terms / custom rows |
#[Sensitive] |
Presenter::sensitiveFieldNames |
Redact in present()->toArray() |
#[Encrypted] |
EncryptionEngine, SinkProjection, AttributeDrivenHydrator |
At-rest encryption — pair with Secret type |
#[ListOf(Element::class)] |
DataObject::coerceParameter |
Coerce each array element into Element |
#[Rule\Required], #[Rule\Email], #[Rule\Min(0)], … |
BuiltInValidator |
Validation in ::fromArrayValidated |
#[DateFormat('Y-m-d')] |
Presenter, sink projection |
Non-default DateTime serialization |
6. Add the relevant traits
The traits are syntactic sugar over PostSource, PostSink, etc. — they make Dto::fromPost($id) and $dto->saveAsPost() work without manual instantiation:
use HasWpSources; // ::fromPost($id), ::fromUser($id), ::fromTerm($id), ::fromOption($name), ::fromRow($row)
use HasWpSinks; // ->saveAsPost(), ->saveAsUser(), ->saveAsTerm(), ->saveAsOption(), ->saveAsRow()
use HasPresenter; // ->present() returns a Presenter builder
Don't add a trait you won't use. Including HasWpSinks on a read-only fixture pollutes the API surface.
7. Realistic example
namespace MyPlugin\Dto;
use BetterData\DataObject;
use BetterData\Secret;
use BetterData\Source\HasWpSources;
use BetterData\Sink\HasWpSinks;
use BetterData\Presenter\HasPresenter;
use BetterData\Attribute\MetaKey;
use BetterData\Attribute\PostField;
use BetterData\Attribute\Encrypted;
use BetterData\Attribute\Sensitive;
use BetterData\Validation\Rule;
final readonly class ProductDto extends DataObject
{
use HasWpSources;
use HasWpSinks;
use HasPresenter;
public function __construct(
public int $id = 0,
#[Rule\Required] public string $post_title = '',
public string $post_status = 'publish',
public string $post_type = 'product',
#[PostField('post_date_gmt')] public ?\DateTimeImmutable $publishedAt = null,
#[MetaKey('_price'), Rule\Min(0)] public float $price = 0.0,
#[MetaKey('_sku'), Rule\Regex('/^[A-Z]{2,4}-\d+$/')] public string $sku = '',
#[MetaKey('_api_key'), Encrypted] public ?Secret $apiKey = null,
#[MetaKey('_notes'), Sensitive] public ?string $notes = null,
) {}
}
Verify the DTO works end-to-end:
vendor/bin/phpunit --filter ProductDto
vendor/bin/phpstan analyse --memory-limit=1G
vendor/bin/php-cs-fixer fix
Critical rules
final readonly class extends DataObject. Never skip final, never skip readonly, never skip extends DataObject. Tools and engines all assume this shape.
- Every constructor parameter has a default OR is nullable. Trailing-without-default cascades and breaks earlier defaults via PHP's Reflection.
int $id = 0, string $foo = '', ?T $bar = null.
?Secret = null, never new Secret(''). An empty-string Secret is worse than no Secret because consumers can't distinguish "intentionally absent" from "set to empty string".
#[Encrypted] requires Secret type. The library tolerates plain-string + #[Encrypted] for backward compatibility but the in-memory value leaks. Always pair them.
- Mutate via
->with([...]), never via setter. with() calls static::fromArray(array_replace($snapshot, $changes)) (src/DataObject.php:119-130), preserving immutability and re-running coercion.
- One trait per concern.
HasWpSources for read, HasWpSinks for write, HasPresenter for output. Add only what you use.
- Specific types over loose ones.
?DateTimeImmutable over ?string, BackedEnum over string, nested DataObject over array.
- Constructor parameter names == hydration keys.
fromArray(['post_title' => 'X']) sets $post_title. Renaming a param is a breaking change for every caller.
Common mistakes
// WRONG — trailing param without default cascades
public function __construct(
public int $id = 0,
public string $name = '',
public ?\DateTimeImmutable $createdAt, // no default → ALL params reported "required"
) {}
// Result: ProductDto::fromArray(['id' => 5]) throws MissingRequiredFieldException for "id"
// even though it has = 0 — because Reflection demoted it.
// RIGHT
public function __construct(
public int $id = 0,
public string $name = '',
public ?\DateTimeImmutable $createdAt = null,
) {}
// WRONG — empty-string Secret as default
#[MetaKey('_api_key'), Encrypted] public Secret $apiKey = new Secret('')
// Looks tidy but: caller can't tell "user never set a key" from "user typed nothing".
// Worse, default expressions in promoted constructor parameters MUST be constants — this
// won't even parse. Use ?Secret = null.
// RIGHT
#[MetaKey('_api_key'), Encrypted] public ?Secret $apiKey = null,
// WRONG — #[Encrypted] on a plain string
#[MetaKey('_api_key'), Encrypted] public string $apiKey = ''
// Ciphertext goes to DB on save, decrypts back on hydration — but in-memory the value is a
// plain string. var_dump($dto), serialize($dto), error logs all leak it.
// RIGHT
#[MetaKey('_api_key'), Encrypted] public ?Secret $apiKey = null,
// WRONG — adding a public mutator
public function setEmail(string $email): void
{
$this->email = $email; // FATAL — readonly property, can't reassign after construction
}
// RIGHT
$updated = $dto->with(['email' => 'new@example.com']);
// WRONG — extending an existing DTO instead of composing
final readonly class PremiumProductDto extends ProductDto
{
public function __construct(public bool $isPremium = false) {}
}
// Library assumes leaf classes; nesting breaks Reflection-based hydration in subtle ways
// (parent's parameters disappear when the child redefines __construct).
// RIGHT — make it a flat class with the extra field, or compose:
final readonly class PremiumProductDto extends DataObject
{
public function __construct(
public ProductDto $base = new ProductDto(),
public bool $isPremium = false,
) {}
}
// WRONG — using snake_case keys on the call site but expecting camelCase params (or vice versa)
public function __construct(public ?\DateTimeImmutable $publishedAt = null) {}
ProductDto::fromArray(['published_at' => '...']); // unmatched key — falls back to default null
// RIGHT — keys must match parameter names exactly. Use #[PostField] / #[Column] only for
// rename when projecting to/from WP storage; in PHP land, keep one canonical name.
Cross-references
- Run
bd-attribute when you need a NEW attribute that isn't in src/Attribute/ yet — wiring an attribute into one engine is a footgun.
- Run
bd-validation-rule when the DTO needs a validation rule that isn't in src/Validation/Rule/.
- Run
bd-security when ANY new field is typed as Secret or carries #[Encrypted] / #[Sensitive] — security review is mandatory for those.
What this skill does NOT cover
- Designing the storage shape itself (which
meta_key to use, which sink to write to). DTO design is type-shape + attributes; storage decisions belong in bd-source-adapter / bd-sink.
- Writing the actual sink or source if better-data doesn't ship one. New WP store integration is
bd-source-adapter + bd-sink.
- Validation logic beyond the built-in rules. New rules go through
bd-validation-rule.
- Presenter customization. Adding a new fluent method or context flag is
bd-presenter.
- Plugin-level DTO testing (companion plugin smoke / stress). Covered by
bd-companion-plugin.
References
- Base class: libraries/better-data/src/DataObject.php:36 —
abstract readonly class. fromArray line 47, coerceParameter line 168, with line 119, fromArrayValidated line 154.
- Required-field guard: libraries/better-data/src/DataObject.php:63-73 —
isDefaultValueAvailable() + allowsNull() check, then MissingRequiredFieldException::for(...).
- Same predicate inside the attribute-aware hydrator: libraries/better-data/src/Internal/AttributeDrivenHydrator.php:90-95.
- Source/sink traits: libraries/better-data/src/Source/HasWpSources.php:28, libraries/better-data/src/Sink/HasWpSinks.php:31, libraries/better-data/src/Presenter/HasPresenter.php:15.
- Attribute carriers: libraries/better-data/src/Attribute/ —
MetaKey, PostField, UserField, TermField, Column, Sensitive, Encrypted, ListOf, DateFormat.
- Built-in rules: libraries/better-data/src/Validation/Rule/ —
Required, Email, Url, Uuid, Min, Max, MinLength, MaxLength, Regex, OneOf, Callback.
- Official documentation: https://github.com/lonsdale201/better-data
- Verified source paths:
src/Attribute/MetaKey.php
src/Attribute/PostField.php
src/Attribute/Encrypted.php
src/Attribute/Sensitive.php
src/Attribute/ListOf.php
src/Attribute/DateFormat.php
src/Validation/Rule/Required.php
src/Secret.php
src/Exception/MissingRequiredFieldException.php
1---2name: bd-data-object3description: Add or modify DataObject subclasses inside the better-data library — the immutable, attribute-decorated DTOs the whole library is built around. Every DTO is final readonly class extends DataObject with constructor-promoted typed parameters; sources hydrate via ::fromArray, sinks project via SinkProjection, the Presenter renders via HasPresenter trait. Important — every trailing constructor parameter MUST have a default; otherwise PHP Reflection reports isDefaultValueAvailable=false on earlier params too and DataObject throws MissingRequiredFieldException at hydration. Also Secret fields default to ?Secret = null (never new Secret('')), encrypt requires the Secret type, and DTOs never grow public mutators (use ->with()). Use when adding a new DTO (e.g. UserProfileDto), adding fields to an existing DTO, or reviewing a PR introducing a class extending DataObject. Triggers on extends DataObject, DataObject::fromArray, ->with(), HasWpSources, HasWpSinks, HasPresenter, MissingRequiredFieldException in better-data.4---56# better-data: Adding a DataObject78For library maintainers and downstream contributors who add or modify a `DataObject` subclass inside [better-data](../../README.md). Every typed shape — production DTOs in `src/`, test fixtures in `tests/Fixtures/`, plugin-level DTOs in the companion testbed — extends the abstract `DataObject` ([src/DataObject.php:36](DataObject.php)) and is the foundation that every engine (sources, sinks, validation, Presenter, REST schema, better-route bridge) reads against.910## Misconception this skill corrects1112> "I'll just declare the constructor parameters with the types I want and set the values when I instantiate the class — defaults are optional."1314In better-data, defaults are **load-bearing**. The hydration entry point `DataObject::fromArray` ([src/DataObject.php:47-79](DataObject.php)) iterates `ReflectionParameter`s and treats any parameter without `isDefaultValueAvailable()` (and without `allowsNull()`) as REQUIRED — throwing `MissingRequiredFieldException`. PHP's Reflection silently demotes earlier-positioned defaults to "required" if a later parameter has no default — so a single missing default at the end cascades and breaks `::fromArray` for the whole DTO.1516Other AI-prone misconceptions:1718- "I'll add `encrypt: true` to the `MetaKey` and store the property as a plain `string`." Wrong shape — `#[Encrypted]` writes ciphertext but the in-memory value is still a plain string that leaks via `var_dump` / `print_r` / `serialize`. Use `Secret` as the property type.19- "I'll add a public mutator method (`setEmail()`) to make consumer code more ergonomic." Wrong — every DTO is `final readonly class`. Mutation is `$dto->with(['email' => 'new@example.com'])` which returns a NEW instance. Mutators break the immutability contract that `Secret`, route-side projection, and Presenter caching all depend on.2021## When to use this skill2223Trigger when ANY of the following is true:2425- Adding a new `final readonly class extends DataObject` under `src/`, `tests/Fixtures/`, or the companion plugin's `Dto/`.26- Adding, removing, or retyping a constructor parameter on an existing DTO.27- The diff or PR title mentions: "new DTO", "add Dto", "add field to <X>Dto", "introduce <Foo>Dto".28- Reviewing a class that extends `DataObject` — use this skill's checklist before approving.29- Hitting `MissingRequiredFieldException` at runtime — usually the cause is a trailing parameter without a default.3031## Workflow3233### 1. Choose the file location3435| What you're building | Path |36|---|---|37| Production DTO (library users hydrate it) | `src/<area>/<Name>Dto.php` |38| Test-only fixture | `tests/Fixtures/<Name>Dto.php` |39| Plugin-level DTO (companion testbed) | `wp-content/plugins/better-data-plugin-test/src/Dto/<Name>Dto.php` |4041### 2. Declare the class4243```php44namespace MyNamespace;4546use BetterData\DataObject;47use BetterData\Source\HasWpSources;48use BetterData\Sink\HasWpSinks;49use BetterData\Presenter\HasPresenter;5051final readonly class ProductDto extends DataObject52{53 use HasWpSources;54 use HasWpSinks;55 use HasPresenter;5657 public function __construct(58 public int $id = 0,59 public string $post_title = '',60 public string $post_status = 'publish',61 ) {}62}63```6465Three non-negotiables:6667- `final` — never extended. The library does not support subclass-of-DTO patterns.68- `readonly` — every property is immutable. Hydration writes once via `newInstanceArgs`; consumers mutate via `->with(...)`.69- `extends DataObject` — gives you `::fromArray`, `::fromArrayValidated`, `->toArray()`, `->with()`, attribute-aware coercion.7071### 3. Constructor-promoted parameters with defaults on every trailing one7273The single most important rule. Every parameter must have either an explicit default OR be nullable. Recommended defaults by type:7475| Type | Default |76|---|---|77| `int` | `= 0` |78| `string` | `= ''` |79| `float` | `= 0.0` |80| `bool` | `= false` |81| `array` | `= []` |82| `?DateTimeImmutable` | `= null` |83| `?Secret` | `= null` (NEVER `new Secret('')`) |84| `BackedEnum` | first case (`= MyEnum::Default`) or `= null` if nullable |8586PHP-side reasoning: `ReflectionParameter::isDefaultValueAvailable()` returns false when ANY required parameter sits later in the signature. The hydrator at [src/DataObject.php:63-73](DataObject.php) checks this exact predicate; missing a default at position N silently breaks defaults at positions 0..N-1.8788### 4. Choose the most-specific type possible8990Better-data leans on type information for coercion, schema generation, and Presenter formatting. Be specific:9192- `?DateTimeImmutable` over `?string` for timestamps — `TypeCoercer` parses ISO-8601 strings and `WC_DateTime` instances automatically.93- `Secret` over `string` for credentials — provides redacted `__toString`, throwing `__serialize`, leak-probe-tested behaviour.94- `BackedEnum` subclass over `string` for closed sets — `TypeCoercer::toEnum` resolves the value or throws `TypeCoercionException`.95- A specific `DataObject` subclass over `array` for nested structures — coercion delegates to `$class::fromArray()` recursively ([src/DataObject.php:195-197](DataObject.php)).9697### 5. Decorate with attributes9899Each attribute is a pure data carrier ([src/Attribute/](Attribute/)) read by one or more engines:100101| Attribute | Read by | Purpose |102|---|---|---|103| `#[MetaKey('key', type: 'number', showInRest: true)]` | `OptionSink`, `PostSink::toMeta`, `RestSchemaBuilder` | Map property to a `meta_key` and REST schema |104| `#[PostField('post_date_gmt')]` | `PostSink`, `PostSource` | Rename DTO param to a `wp_posts` column |105| `#[UserField]`, `#[TermField]`, `#[Column]` | corresponding sink/source | Same but for users / terms / custom rows |106| `#[Sensitive]` | `Presenter::sensitiveFieldNames` | Redact in `present()->toArray()` |107| `#[Encrypted]` | `EncryptionEngine`, `SinkProjection`, `AttributeDrivenHydrator` | At-rest encryption — pair with `Secret` type |108| `#[ListOf(Element::class)]` | `DataObject::coerceParameter` | Coerce each array element into `Element` |109| `#[Rule\Required]`, `#[Rule\Email]`, `#[Rule\Min(0)]`, … | `BuiltInValidator` | Validation in `::fromArrayValidated` |110| `#[DateFormat('Y-m-d')]` | Presenter, sink projection | Non-default DateTime serialization |111112### 6. Add the relevant traits113114The traits are syntactic sugar over `PostSource`, `PostSink`, etc. — they make `Dto::fromPost($id)` and `$dto->saveAsPost()` work without manual instantiation:115116```php117use HasWpSources; // ::fromPost($id), ::fromUser($id), ::fromTerm($id), ::fromOption($name), ::fromRow($row)118use HasWpSinks; // ->saveAsPost(), ->saveAsUser(), ->saveAsTerm(), ->saveAsOption(), ->saveAsRow()119use HasPresenter; // ->present() returns a Presenter builder120```121122Don't add a trait you won't use. Including `HasWpSinks` on a read-only fixture pollutes the API surface.123124### 7. Realistic example125126```php127namespace MyPlugin\Dto;128129use BetterData\DataObject;130use BetterData\Secret;131use BetterData\Source\HasWpSources;132use BetterData\Sink\HasWpSinks;133use BetterData\Presenter\HasPresenter;134use BetterData\Attribute\MetaKey;135use BetterData\Attribute\PostField;136use BetterData\Attribute\Encrypted;137use BetterData\Attribute\Sensitive;138use BetterData\Validation\Rule;139140final readonly class ProductDto extends DataObject141{142 use HasWpSources;143 use HasWpSinks;144 use HasPresenter;145146 public function __construct(147 public int $id = 0,148 #[Rule\Required] public string $post_title = '',149 public string $post_status = 'publish',150 public string $post_type = 'product',151 #[PostField('post_date_gmt')] public ?\DateTimeImmutable $publishedAt = null,152 #[MetaKey('_price'), Rule\Min(0)] public float $price = 0.0,153 #[MetaKey('_sku'), Rule\Regex('/^[A-Z]{2,4}-\d+$/')] public string $sku = '',154 #[MetaKey('_api_key'), Encrypted] public ?Secret $apiKey = null,155 #[MetaKey('_notes'), Sensitive] public ?string $notes = null,156 ) {}157}158```159160Verify the DTO works end-to-end:161162```bash163vendor/bin/phpunit --filter ProductDto164vendor/bin/phpstan analyse --memory-limit=1G165vendor/bin/php-cs-fixer fix166```167168## Critical rules169170- **`final readonly class extends DataObject`.** Never skip `final`, never skip `readonly`, never skip `extends DataObject`. Tools and engines all assume this shape.171- **Every constructor parameter has a default OR is nullable.** Trailing-without-default cascades and breaks earlier defaults via PHP's Reflection. `int $id = 0`, `string $foo = ''`, `?T $bar = null`.172- **`?Secret = null`, never `new Secret('')`.** An empty-string Secret is worse than no Secret because consumers can't distinguish "intentionally absent" from "set to empty string".173- **`#[Encrypted]` requires `Secret` type.** The library tolerates plain-string + `#[Encrypted]` for backward compatibility but the in-memory value leaks. Always pair them.174- **Mutate via `->with([...])`, never via setter.** `with()` calls `static::fromArray(array_replace($snapshot, $changes))` ([src/DataObject.php:119-130](DataObject.php)), preserving immutability and re-running coercion.175- **One trait per concern.** `HasWpSources` for read, `HasWpSinks` for write, `HasPresenter` for output. Add only what you use.176- **Specific types over loose ones.** `?DateTimeImmutable` over `?string`, `BackedEnum` over `string`, nested `DataObject` over `array`.177- **Constructor parameter names == hydration keys.** `fromArray(['post_title' => 'X'])` sets `$post_title`. Renaming a param is a breaking change for every caller.178179## Common mistakes180181```php182// WRONG — trailing param without default cascades183public function __construct(184 public int $id = 0,185 public string $name = '',186 public ?\DateTimeImmutable $createdAt, // no default → ALL params reported "required"187) {}188// Result: ProductDto::fromArray(['id' => 5]) throws MissingRequiredFieldException for "id"189// even though it has = 0 — because Reflection demoted it.190191// RIGHT192public function __construct(193 public int $id = 0,194 public string $name = '',195 public ?\DateTimeImmutable $createdAt = null,196) {}197198// WRONG — empty-string Secret as default199#[MetaKey('_api_key'), Encrypted] public Secret $apiKey = new Secret('')200// Looks tidy but: caller can't tell "user never set a key" from "user typed nothing".201// Worse, default expressions in promoted constructor parameters MUST be constants — this202// won't even parse. Use ?Secret = null.203204// RIGHT205#[MetaKey('_api_key'), Encrypted] public ?Secret $apiKey = null,206207// WRONG — #[Encrypted] on a plain string208#[MetaKey('_api_key'), Encrypted] public string $apiKey = ''209// Ciphertext goes to DB on save, decrypts back on hydration — but in-memory the value is a210// plain string. var_dump($dto), serialize($dto), error logs all leak it.211212// RIGHT213#[MetaKey('_api_key'), Encrypted] public ?Secret $apiKey = null,214215// WRONG — adding a public mutator216public function setEmail(string $email): void217{218 $this->email = $email; // FATAL — readonly property, can't reassign after construction219}220221// RIGHT222$updated = $dto->with(['email' => 'new@example.com']);223224// WRONG — extending an existing DTO instead of composing225final readonly class PremiumProductDto extends ProductDto226{227 public function __construct(public bool $isPremium = false) {}228}229// Library assumes leaf classes; nesting breaks Reflection-based hydration in subtle ways230// (parent's parameters disappear when the child redefines __construct).231232// RIGHT — make it a flat class with the extra field, or compose:233final readonly class PremiumProductDto extends DataObject234{235 public function __construct(236 public ProductDto $base = new ProductDto(),237 public bool $isPremium = false,238 ) {}239}240241// WRONG — using snake_case keys on the call site but expecting camelCase params (or vice versa)242public function __construct(public ?\DateTimeImmutable $publishedAt = null) {}243ProductDto::fromArray(['published_at' => '...']); // unmatched key — falls back to default null244245// RIGHT — keys must match parameter names exactly. Use #[PostField] / #[Column] only for246// rename when projecting to/from WP storage; in PHP land, keep one canonical name.247```248249## Cross-references250251- Run **`bd-attribute`** when you need a NEW attribute that isn't in `src/Attribute/` yet — wiring an attribute into one engine is a footgun.252- Run **`bd-validation-rule`** when the DTO needs a validation rule that isn't in `src/Validation/Rule/`.253- Run **`bd-security`** when ANY new field is typed as `Secret` or carries `#[Encrypted]` / `#[Sensitive]` — security review is mandatory for those.254255## What this skill does NOT cover256257- Designing the storage shape itself (which `meta_key` to use, which sink to write to). DTO design is type-shape + attributes; storage decisions belong in `bd-source-adapter` / `bd-sink`.258- Writing the actual sink or source if better-data doesn't ship one. New WP store integration is `bd-source-adapter` + `bd-sink`.259- Validation logic beyond the built-in rules. New rules go through `bd-validation-rule`.260- Presenter customization. Adding a new fluent method or context flag is `bd-presenter`.261- Plugin-level DTO testing (companion plugin smoke / stress). Covered by `bd-companion-plugin`.262263## References264265- Base class: [libraries/better-data/src/DataObject.php:36](DataObject.php) — `abstract readonly class`. `fromArray` line 47, `coerceParameter` line 168, `with` line 119, `fromArrayValidated` line 154.266- Required-field guard: [libraries/better-data/src/DataObject.php:63-73](DataObject.php) — `isDefaultValueAvailable()` + `allowsNull()` check, then `MissingRequiredFieldException::for(...)`.267- Same predicate inside the attribute-aware hydrator: [libraries/better-data/src/Internal/AttributeDrivenHydrator.php:90-95](AttributeDrivenHydrator.php).268- Source/sink traits: [libraries/better-data/src/Source/HasWpSources.php:28](HasWpSources.php), [libraries/better-data/src/Sink/HasWpSinks.php:31](HasWpSinks.php), [libraries/better-data/src/Presenter/HasPresenter.php:15](HasPresenter.php).269- Attribute carriers: [libraries/better-data/src/Attribute/](Attribute/) — `MetaKey`, `PostField`, `UserField`, `TermField`, `Column`, `Sensitive`, `Encrypted`, `ListOf`, `DateFormat`.270- Built-in rules: [libraries/better-data/src/Validation/Rule/](Rule/) — `Required`, `Email`, `Url`, `Uuid`, `Min`, `Max`, `MinLength`, `MaxLength`, `Regex`, `OneOf`, `Callback`.271- Official documentation: <https://github.com/lonsdale201/better-data>272- Verified source paths:273 - `src/Attribute/MetaKey.php`274 - `src/Attribute/PostField.php`275 - `src/Attribute/Encrypted.php`276 - `src/Attribute/Sensitive.php`277 - `src/Attribute/ListOf.php`278 - `src/Attribute/DateFormat.php`279 - `src/Validation/Rule/Required.php`280 - `src/Secret.php`281 - `src/Exception/MissingRequiredFieldException.php`