version 1.0 · PHP 8.3+ DTO & Presenter library for WordPress
Typed hydration of WordPress data into immutable DTOs, contextual output shaping back to REST / admin / email / CSV. WordPress-aware without pulling you into an ORM. Composer-friendly, framework-free, MIT-licensed.
better-data gives a WordPress plugin or theme three small layers that
work together:
- DataObject — typed, immutable(-ish) DTOs declared via PHP 8's
readonly class+ constructor-promoted properties. Hydrate from any shape (array,WP_Post,WP_User, REST request,wpdbrow, option payload); serialize back when needed. - Sources / Sinks — WP-aware adapters that move values between your DTOs and WordPress storage (posts + meta, users + meta, terms + meta, options, custom tables). Every sink has a projection layer so you can inspect or test the write payload before it hits the DB.
- Presenter — contextual output shaping. The same DTO becomes a
REST response, an admin-list row, a CSV line, or a localized email
body depending on the
PresentationContextyou hand it.
On top of that sit the security primitives — Secret, #[Sensitive],
#[Encrypted] — a validation layer that plays nicely with
register_rest_route's arg validator, and a registry that feeds
register_meta from your DTO shape.
- Not an ORM. No relationships, no query builder, no lazy loading,
no schema migrations. Use whichever query layer your plugin already
has (
WP_Query,get_users,$wpdb) and hand the results to a source. - Not a form renderer. Data shaping only — HTML is the consumer's problem.
- Not a DI container / service bus / router. Our sibling library
better-routehandles the routing layer; better-data only ships an optional bridge that lets DTOs feed better-route handlers, args, and OpenAPI metadata. - Not a secrets manager.
Secret+#[Encrypted]provide leak-proof in-memory handling and at-rest AES-256-GCM for meta and option values. Key distribution, rotation, and vault integration are the consumer's responsibility.
- PHP 8.3+ (readonly classes, readonly-clone, typed class
constants,
#[\Override],json_validate()) - WordPress (tested against current stable; no version floor enforced — the sources fall back gracefully when optional WP functions are absent)
ext-opensslwhen you use#[Encrypted](ships with every modern PHP)
The package is not on Packagist yet. Install it via a Composer VCS repository pointing at the public GitHub repo:
{
"require": {
"better-data/better-data": "^1.0"
},
"repositories": [
{
"type": "vcs",
"url": "https://github.com/Lonsdale201/better-data"
}
],
"prefer-stable": true
}Then:
composer update better-data/better-dataAfter the package lands on Packagist you can drop the repositories
block and keep only require.
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('_stock', type: 'integer', showInRest: true)]
public int $stock = 0,
#[MetaKey('_sku', type: 'string', 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,
) {}
}// 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 at read)
// Immutable update
$updated = $product->with(['price' => 24.99]);
// Persist back (insert if id=0, update otherwise)
$updated->saveAsPost();
// Bulk-hydrate efficiently (post + meta prewarmed in 2 SQL queries)
$products = ProductDto::fromPosts([1, 2, 3, 4, 5]);$result = $product->validate();
if (!$result->isValid()) {
foreach ($result->flatten() as $error) {
error_log($error);
}
}
// Or fail-fast during hydration:
$product = ProductDto::fromArrayValidated($_POST);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();
// Admin list
$rows = Presenter::forCollection($products)
->context(PresentationContext::admin())
->hideUnlessCan('cost_breakdown', 'manage_options')
->formatDate('publishedAt', 'Y-m-d H:i')
->toArray();use BetterData\Registration\MetaKeyRegistry;
add_action('init', function () {
register_post_type('product', [...]);
MetaKeyRegistry::register(
ProductDto::class,
objectType: 'post',
subtype: 'product',
);
});
// Generate register_rest_route args from the DTO shape
add_action('rest_api_init', function () {
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(),
]);
});BetterData\Route\BetterRouteBridge wires a DTO into a
better-route Router without making better-route a hard dependency of
this package.
use BetterData\Route\BetterRouteBridge;
use BetterRoute\BetterRoute;
add_action('rest_api_init', function () {
$router = BetterRoute::router('shop', 'v1');
BetterRouteBridge::post(
$router,
'/products',
ProductDto::class,
function (ProductDto $dto): ProductDto {
$id = $dto->saveAsPost();
return $dto->with(['id' => $id]);
},
[
'operationId' => 'productsCreate',
'tags' => ['Products'],
'envelope' => true,
'permissionCallback' => static fn (): bool => current_user_can('edit_posts'),
],
);
BetterRouteBridge::patch(
$router,
'/products/(?P<id>\d+)',
ProductDto::class,
function (ProductDto $dto): ProductDto {
$dto->saveAsPost(only: ['price', 'stock']);
return $dto;
},
[
'source' => 'json',
'routeFields' => ['id'],
'operationId' => 'productsUpdate',
'tags' => ['Products'],
'envelope' => true,
'permissionCallback' => static fn (): bool => current_user_can('edit_posts'),
],
);
$router->register();
});The bridge:
- hydrates and validates the DTO from query / JSON / body / URL params
- marks route-owned fields (for example
id) as URL-authoritative and rejects body/query collisions - feeds
MetaKeyRegistry::toRestArgs()intoRouteBuilder::args() - feeds generated
requestSchema,responseSchema, tags, scopes, and parameters intoRouteBuilder::meta() - presents returned
DataObjectvalues throughPresenterwithPresentationContext::rest()
For better-route's OpenAPI exporter, merge DTO schemas into the exporter options:
$components = BetterData\Route\BetterRouteBridge::openApiComponents([
ProductDto::class,
]);
$openApi = BetterRoute::openApiExporter()->export(
$router->contracts(true),
['components' => $components],
); ┌──────────────┐
│ Consumer │ (your plugin / theme)
└──────┬───────┘
│
┌───────────────────┼───────────────────┐
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌──────────┐
│ Source │ │ DataObj │ │ Presenter│
│ adapter │───────►│ (DTO) │───────►│ layer │
│ │ │ │ │ │
│ PostSrc │ │ readonly │ │ REST │
│ UserSrc │ │ typed │ │ admin │
│ TermSrc │◄───────│ │───────►│ email │
│ Option │ │ Secret/ │ │ CSV │
│ Row │ │ DateTime │ │ JSON │
│ Request │ │ Enum │ │ │
└────┬────┘ └────┬─────┘ └──────────┘
│ │
▼ ▼
┌─────────┐ ┌──────────┐
│ TypeCrc │ │ Sink │
│ coerce │ │ adapter │
│ strings │ │ │
│ → types │ │ PostSink │
└─────────┘ │ UserSink │
│ TermSink │
│ Option │
│ Row │
└──────────┘
│
▼
┌──────────┐
│ WordPress│
│ (posts, │
│ users, │
│ meta, │
│ options)│
└──────────┘
The base class. Subclasses declare their shape via constructor
promotion, must be readonly, and get for free:
| Method | What it does |
|---|---|
::fromArray($data) |
Hydrate from a scalar-keyed array (type coercion runs) |
::fromArrayValidated($data) |
Same + throws ValidationException if rules fail |
->toArray() |
Serialize to an array — Secrets redact to '***', enums unwrap to scalar, DateTime to ATOM |
->with(['field' => $new]) |
Return a new instance with selected fields replaced (preserves Secret, DateTime, Enum rich types) |
->validate() |
Returns a ValidationResult |
Type coercion handles string → int/float/bool, ISO strings →
DateTimeImmutable, scalar → BackedEnum, array → nested
DataObject, string → Secret, and — with #[ListOf] — arrays into
typed lists.
| Source | Reads from |
|---|---|
PostSource |
WP_Post system fields + post_meta |
UserSource |
WP_User + user_meta |
TermSource |
WP_Term + term_meta |
OptionSource |
wp_options |
RowSource |
Any $wpdb row (ARRAY_A or object) |
RequestSource |
WP_REST_Request with nonce / cap / param-source guards |
All sources are WP-independent at the engine level
(AttributeDrivenHydrator) so you can unit-test your DTO hydration
without bootstrapping WordPress.
Add use HasWpSources; to a DTO for the convenience shortcuts
::fromPost, ::fromPosts, ::fromUser, ::fromUsers, ::fromTerm,
::fromRow, ::fromRows, ::fromOption, ::fromRequest.
| Sink | Writes to |
|---|---|
PostSink |
wp_insert_post / wp_update_post + meta |
UserSink |
wp_insert_user / wp_update_user + meta (excludes user_pass, user_activation_key) |
TermSink |
wp_insert_term / wp_update_term + meta |
OptionSink |
update_option (+ #[Encrypted] at-rest support) |
RowSink |
$wpdb->insert / $wpdb->update |
Every sink supports:
- Projection-first API (
toArgs/toMeta/toArray) — returns the payload without touching the DB. Unit-test friendly. - Partial writes via
$only: ['field', 'field']— only the listed properties are written. - Strict whitelist via
strict: true— typos in$onlythrowUnknownFieldException. skipNullDeletes: true— PATCH-style update wherenullin the DTO leaves existing meta untouched instead of deleting it.wp_slash()on convenience methods — backslashes in payloads survive the round-trip.
Add use HasWpSinks; for ->saveAsPost, ->saveAsUser,
->saveAsTerm, ->saveAsOption, ->saveAsRow.
Fluent builder that projects a DataObject into a context-specific output shape.
Presenter::for($product)
->context(PresentationContext::rest())
->only(['id', 'title', 'price', 'priceFormatted'], strict: true)
->hide('cost', when: fn ($ctx) => !$ctx->userCan('manage_options'))
->showOnlyFor('wholesale_price', roles: ['wholesale'])
->rename('post_title', 'title')
->compute('priceFormatted', fn ($p, $ctx) => wc_price($p->price))
->formatDate('publishedAt', 'F j, Y')
->formatCurrency('price')
->includeSensitive(['internalNote']) // opt-in for Sensitive plain-string fields
->toJson(); // always throws on encode failureSubclass Presenter and override configure() when logic warrants a
dedicated class; Presenter::forCollection($dtos) replays the same
configuration over every item.
PresentationContext carries timezone + locale + current user + a
free-form name. Locale flows into switch_to_locale() for the built-in
formatters, so withLocale('hu_HU') on an en_US site produces
Hungarian month names for that render.
Attribute-driven built-in validator. Supply your own via
ValidationEngineInterface if you already use Symfony Validator,
Respect, or Laravel's.
Built-in rules:
Required,Email,Url,Uuid,RegexMin,Max,MinLength,MaxLengthOneOf([...])Callback(closure — not usable as an attribute, constructed programmatically)
Hydration stays shape-only; validation is an explicit, separate step
(->validate() or the throwing ::fromArrayValidated() shortcut).
Rules short-circuit per field: once Required fails, further rules
on the same field are skipped so you don't get
Required → Email → MinLength cascades for one empty input.
Three composable primitives, each addressing a different concern.
| Tool | Concern | Applies to |
|---|---|---|
Secret (type) |
In-memory leak prevention for credentials | public Secret $apiKey — blocks __toString, json_encode, var_dump, print_r, serialize |
#[Sensitive] (attribute) |
PII default-redaction for plain strings | #[Sensitive] public string $ipAddress — Presenter excludes unless includeSensitive() opts in |
#[Encrypted] (attribute) |
At-rest AES-256-GCM on storage | #[Encrypted] public Secret $apiKey — sink encrypts on write, source decrypts on read |
They compose naturally: #[Encrypted] public Secret $apiKey =
encrypted on disk + leak-proof in memory. Secret alone = in-memory
only (still plaintext on disk). #[Sensitive] on a plain string =
presentation guard only.
$key = BetterData\Encryption\EncryptionEngine::generateKey();
// Put in wp-config.php:
define('BETTER_DATA_ENCRYPTION_KEY', $key);
// Optional for rotation:
define('BETTER_DATA_ENCRYPTION_KEY_PREVIOUS', $oldKey);Missing key when an #[Encrypted] field is read/written →
MissingEncryptionKeyException (loud failure, never silent plaintext
storage). Tampered or corrupt ciphertext →
DecryptionFailedException with a generic message (no oracle leak).
See Security model below for the full threat model.
MetaKeyRegistry::register(ProductDto::class, 'post', 'product')
walks every #[MetaKey]-annotated constructor parameter and calls
register_meta() with shape info (type, single, default, description,
sanitize_callback, auth_callback) derived from the attribute. It does
not register post types, taxonomies, or REST routes — those are
app-level decisions.
Two schema projections:
toJsonSchema($dto)— root-object JSON Schema, drops into OpenAPIcomponents/schemas/<Name>.toRestArgs($dto)— flat per-field map forregister_rest_route(['args' => ...]). Every entry includesrequired,type,description, and any constraints the rule attributes imply (format: email,enum: [...],pattern, etc.).
- Accidental serialization of secrets. A Secret typed field
survives
json_encode,var_dump,print_r, exception stack traces, DataObjecttoArray, Presenter default output, andserialize()(which throws rather than redact — a silent lossy serialize of a credential is the worst of both outcomes). - Plaintext at rest.
#[Encrypted]on a meta- or option-backed field routes writes through AES-256-GCM before the value reachesupdate_post_meta/update_option. Reads decrypt transparently. - Client-driven id spoofing at REST.
RequestSource::noCollisionblocks body params from overriding route-owned fields (PUT /widgets/{id}with{id: 999}in the body). - Missing-auth-callback footguns.
MetaKeyRegistryemits_doing_it_wrongwhen a_-prefixed meta is exposed viashowInRestwithoutauthCapability(WP defaults protected meta auth to__return_false, silent 403), and again whenencrypt: truemeetsshowInRest: true(WP core's REST read path bypasses our decryption). - Slash-munging. All convenience sink methods pass values through
wp_slash(); projection methods leave values raw so you can inspect / substitute.
- Reflection and debuggers. PHP reflection can always read
private properties. Extensions like Xdebug can inspect anything.
Secretdefends accidental leaks, not determined introspection. - Memory dumps / swap files. PHP strings are immutable; zeroing isn't meaningful. Operate at the OS level if this is in scope.
- Key management. We read
BETTER_DATA_ENCRYPTION_KEYfrom wp-config. Distribution, rotation schedules, vault integration, HSMs — out of scope. - Rate limiting, audit logs, intrusion detection. Not our layer.
-
Define the current primary as
_PREVIOUSwhile keeping it asPRIMARY:// wp-config.php define('BETTER_DATA_ENCRYPTION_KEY', 'base64-new-key'); define('BETTER_DATA_ENCRYPTION_KEY_PREVIOUS', 'base64-old-key');
-
Deploy. Writes use the new primary; reads try primary then previous on failure.
-
Run a one-shot migration: for every DTO that carries
#[Encrypted]fields, hydrate → call->with([...])with no actual changes → save. The save re-encrypts with the new primary. -
Once coverage is complete, remove
_PREVIOUSfrom wp-config on the next deploy.
- Zero magic by default. Attributes add metadata when metadata earns its keep; plain DTOs work without any. Type coercion is deterministic and documented.
- Explicit security. No "helpful" side effects around credentials. Reveal, encrypt, redact — every path through the type system is opt-in at the call site.
- Loud failures over silent corruption. Missing encryption keys throw, tampered ciphertext throws, strict whitelists throw on typos, collision guards throw on route-shadowing attempts.
- Projection before persistence. Every sink has a
toArgs/toMeta/toArraythat returns what the sink would write. Unit-test your wiring without a live WP. - Small surface, composable. We export a handful of attributes, a base DataObject, and six sink/source pairs. No abstract factories, no service container, no event bus.
The library itself runs under PHPUnit + PHPStan level 6. Consumers get two test paths:
- Unit — source/sink engines are WP-independent. Hydrate test
fixtures with a fake meta fetcher; no
WP_Postrequired. - Integration — the companion plugin
better-data-plugin-testregisters a realistic widget CPT, REST routes fromtoRestArgs(),save_posthook, and admin pages.wp better-data testruns the regression smoke;wp better-data stressruns the deep integration scenarios (RestCrudCycle, encryption round-trip, collision guards, etc.).
Semver from v1.0 onwards. Public API surface — DataObject,
attributes, sources, sinks, Presenter, MetaKeyRegistry,
BetterRouteBridge — is stable. Breaking changes require a major
bump and migration notes.
MIT © Soczó Kristóf
better-route— sibling routing library. UseBetterData\Route\BetterRouteBridgewhen a route should hydrate, validate, document, and present better-data DTOs.