Quick Start
A working DTO with WP-aware hydration, validation, and presentation in five minutes.
1. Declare a DTO
use BetterData\Attribute\Encrypted;
use BetterData\Attribute\MetaKey;
use BetterData\Attribute\PostField;
use BetterData\Attribute\Sensitive;
use BetterData\DataObject;
use BetterData\Secret;
use BetterData\Sink\HasWpSinks;
use BetterData\Source\HasWpSources;
use BetterData\Validation\Rule;
final readonly class ProductDto extends DataObject
{
use HasWpSources;
use HasWpSinks;
public function __construct(
public int $id = 0,
#[Rule\Required, Rule\MinLength(2)]
public string $post_title = '',
public string $post_status = 'publish',
public string $post_type = 'product',
#[PostField('post_date_gmt')]
public ?\DateTimeImmutable $publishedAt = null,
#[MetaKey('_price', type: 'number', showInRest: true)]
#[Rule\Min(0)]
public float $price = 0.0,
#[MetaKey('_sku', showInRest: true)]
#[Rule\Required, Rule\Regex('/^[A-Z]{2,4}-\d+$/')]
public string $sku = '',
#[MetaKey('_vendor_api_key'), Encrypted]
public ?Secret $vendorApiKey = null,
#[MetaKey('_internal_note'), Sensitive]
public ?string $internalNote = null,
) {}
}
Three rules to remember:
final readonly classextendingDataObject— immutability is load-bearing forSecret,with(), and sink projections.- Every trailing constructor parameter has a default. PHP demotes earlier defaults to "required" if a later parameter has none, and you'll get
MissingRequiredFieldExceptionat hydration. - Attributes are optional unless you need them — bare DTOs with matching property names hydrate from posts/users without
#[PostField]etc.
2. Read
// Hydrate from a post id
$product = ProductDto::fromPost(42);
$product->price; // 19.99 (coerced from WP's string meta)
$product->vendorApiKey->reveal(); // 'sk_live_abc…' (decrypted on read)
// Bulk-hydrate efficiently — 2 SQL queries for any number of posts
$products = ProductDto::fromPosts([1, 2, 3, 4, 5]);
3. Write
// Immutable update
$updated = $product->with(['price' => 24.99]);
// Persist back: insert if id=0, update otherwise
$updated->saveAsPost();
// Partial update (only listed fields, ignore everything else)
$updated->saveAsPost(only: ['price', 'sku']);
4. Validate
$result = $product->validate();
if (!$result->isValid()) {
foreach ($result->flatten() as $error) {
error_log($error);
}
}
// Or fail-fast during hydration:
$product = ProductDto::fromArrayValidated($_POST);
5. Present
use BetterData\Presenter\PresentationContext;
use BetterData\Presenter\Presenter;
// REST JSON — Secret and #[Sensitive] fields auto-excluded
$json = Presenter::for($product)
->context(PresentationContext::rest())
->only(['id', 'post_title', 'price', 'sku', 'priceFormatted'])
->compute('priceFormatted', fn ($p) => wc_price($p->price))
->rename('post_title', 'title')
->toJson();
6. Hook into WP REST
use BetterData\Registration\MetaKeyRegistry;
add_action('init', function (): void {
register_post_type('product', [...]);
MetaKeyRegistry::register(
ProductDto::class,
objectType: 'post',
subtype: 'product',
);
});
add_action('rest_api_init', function (): void {
register_rest_route('shop/v1', '/products', [
'methods' => 'POST',
'args' => MetaKeyRegistry::toRestArgs(ProductDto::class),
'callback' => fn (\WP_REST_Request $r) =>
\BetterData\Source\RequestSource::from($r)
->requireNonce('shop_save')
->requireCapability('edit_posts')
->bodyOnly()
->into(ProductDto::class)
->saveAsPost(),
]);
});
7. Compose with better-route
use BetterData\Route\BetterRouteBridge;
use BetterRoute\BetterRoute;
add_action('rest_api_init', function (): void {
$router = BetterRoute::router('shop', 'v1');
BetterRouteBridge::post(
$router,
'/products',
ProductDto::class,
fn (ProductDto $dto) => $dto->saveAsPost(),
['operationId' => 'productsCreate', 'tags' => ['Products']],
);
$router->register();
});
The bridge hydrates and validates the DTO from the request, runs your handler, presents the returned DataObject through PresentationContext::rest(), and emits MetaKeyRegistry-derived OpenAPI metadata. See Composition → BetterRouteBridge for the full options surface.
Validation checklist
- DTO instances hydrate without throwing
TypeCoercionException validate()returnsisValid() === truefor known-good payloadsPresenter::for($dto)->toArray()redactsSecretto'***'and excludes#[Sensitive]fields by defaultMetaKeyRegistry::register()is called insideinit— not at file load time