Bd data object
Skill Lonsdale201/wp-agent-skills/better-data/bd-data-object
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.From its SKILL.md
npx -y skills add Lonsdale201/wp-agent-skills --skill bd-data-objectAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- 21 stars21 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
- runs commandsInstructs the agent to run 3 commands, including `vendor/bin/phpunit --filter ProductDto` and 2 more.
SKILL.md
15.8 KB, ~3.7k tokens by cl100k_base, as published. Nobody here has run it
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: trueto theMetaKeyand store the property as a plainstring." Wrong shape —#[Encrypted]writes ciphertext but the in-memory value is still a plain string that leaks viavar_dump/print_r/serialize. UseSecretas the property type. - "I'll add a public mutator method (
setEmail()) to make consumer code more ergonomic." Wrong — every DTO isfinal readonly class. Mutation is$dto->with(['email' => '[email protected]'])which returns a NEW instance. Mutators break the immutability contract thatSecret, 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 DataObjectundersrc/,tests/Fixtures/, or the companion plugin'sDto/. - Adding, removing, or retyping a constructor parameter on an existing DTO.
- The diff or PR title mentions: "new DTO", "add Dto", "add field to <X>Dto", "introduce <Foo>Dto".
- Reviewing a class that extends
DataObject— use this skill's checklist before approving. - Hitting
MissingRequiredFieldExceptionat 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 vianewInstanceArgs; 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:
?DateTimeImmutableover?stringfor timestamps —TypeCoercerparses ISO-8601 strings andWC_DateTimeinstances automatically.Secretoverstringfor credentials — provides redacted__toString, throwing__serialize, leak-probe-tested behaviour.BackedEnumsubclass overstringfor closed sets —TypeCoercer::toEnumresolves the value or throwsTypeCoercionException.- A specific
DataObjectsubclass overarrayfor 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 skipfinal, never skipreadonly, never skipextends 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, nevernew Secret(''). An empty-string Secret is worse than no Secret because consumers can't distinguish "intentionally absent" from "set to empty string".#[Encrypted]requiresSecrettype. 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()callsstatic::fromArray(array_replace($snapshot, $changes))(src/DataObject.php:119-130), preserving immutability and re-running coercion. - One trait per concern.
HasWpSourcesfor read,HasWpSinksfor write,HasPresenterfor output. Add only what you use. - Specific types over loose ones.
?DateTimeImmutableover?string,BackedEnumoverstring, nestedDataObjectoverarray. - 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' => '[email protected]']);
// 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-attributewhen you need a NEW attribute that isn't insrc/Attribute/yet — wiring an attribute into one engine is a footgun. - Run
bd-validation-rulewhen the DTO needs a validation rule that isn't insrc/Validation/Rule/. - Run
bd-securitywhen ANY new field is typed asSecretor carries#[Encrypted]/#[Sensitive]— security review is mandatory for those.
What this skill does NOT cover
- Designing the storage shape itself (which
meta_keyto use, which sink to write to). DTO design is type-shape + attributes; storage decisions belong inbd-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.fromArrayline 47,coerceParameterline 168,withline 119,fromArrayValidatedline 154. - Required-field guard: libraries/better-data/src/DataObject.php:63-73 —
isDefaultValueAvailable()+allowsNull()check, thenMissingRequiredFieldException::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.phpsrc/Attribute/PostField.phpsrc/Attribute/Encrypted.phpsrc/Attribute/Sensitive.phpsrc/Attribute/ListOf.phpsrc/Attribute/DateFormat.phpsrc/Validation/Rule/Required.phpsrc/Secret.phpsrc/Exception/MissingRequiredFieldException.php
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.