WordPress plugin: native DTOs
For plugin code that moves structured data between request input, options,
meta, custom tables, external APIs, REST controllers, admin screens, cron jobs,
and presenters. A DTO gives that data one named shape instead of leaking raw
arrays across the plugin.
This skill is intentionally better-data-free. If the project already uses
better-data, run bd-data-object; otherwise use this native pattern.
When to load references
- Need a complete PHP 7.4 / PHP 8.1 DTO implementation, nested DTO, enum-like value set, or secret value object: read references/native-dto-patterns.md.
- Refactoring a controller/repository that currently passes raw arrays,
$_POST, WP_Post, meta arrays, or option arrays around: read references/before-after-raw-array.md.
Misconception this skill corrects
"A DTO is just an array with nicer comments."
Wrong direction. A DTO is a small immutable boundary object with a named schema,
explicit defaults, and one hydration path. It prevents common AI mistakes:
unchecked (int) casts, empty() swallowing 0, isset() hiding intentional
null, dynamic properties, leaking secrets through to_array(), and passing
$_POST or raw post meta directly to business logic.
When to use this skill
Trigger when ANY of the following is true:
- Introducing
FooDto, FooData, SettingsData, RequestData, Payload, or a value object.
- A repository, REST controller, admin page, cron job, or WooCommerce hook currently passes large associative arrays around.
- Data comes from
$_GET, $_POST, REST params, options, post meta, user meta, term meta, custom tables, WP_Post, WP_User, or WooCommerce objects.
- Reviewing hydration / normalization code with casts like
(int), (bool), intval, settype, empty, isset, get_object_vars, or dynamic property assignment.
- Presenter or REST response code needs a stable input object.
Layer boundaries
| Layer |
Responsibility |
| Source / repository |
Read WP objects, options, meta, request params, API responses. Knows WordPress. |
| DTO |
Hold normalized values. No DB writes, no HTML, no hooks, no global reads. |
| Validator |
Decide whether the DTO is acceptable. Returns true or WP_Error. |
| Presenter |
Convert DTO to arrays / JSON-ready values for REST, admin, JS config, email. |
| View / template |
Escape and echo HTML. |
The DTO may contain small normalization helpers, but it should not call
get_post_meta(), update_option(), wp_remote_get(), add_action(), or echo
anything.
Minimal class shape
Prefer PHP 7.4-compatible immutable objects unless the plugin has a higher
minimum PHP version. Use PHP 8.1 readonly only when the plugin declares PHP
8.1+.
namespace MyPlugin\Dto;
use WP_Error;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
final class ProductDto {
private int $id;
private string $title;
public function __construct( int $id = 0, string $title = '' ) {
$this->id = $id;
$this->title = $title;
}
/** @param array<string,mixed> $data @return self|WP_Error */
public static function from_array( array $data ) {
$errors = new WP_Error();
$id = self::to_int( $data['id'] ?? 0, 'id', $errors );
$title = self::to_string( $data['title'] ?? '', 'title', $errors );
if ( '' === $title ) {
$errors->add( 'missing_title', 'Product title is required.' );
}
if ( $errors->has_errors() ) {
return $errors;
}
return new self( $id, $title );
}
/** @return array<string,mixed> */
public function to_array(): array {
return array(
'id' => $this->id,
'title' => $this->title,
);
}
public function id(): int { return $this->id; }
public function title(): string { return $this->title; }
private static function to_int( $value, string $field, WP_Error $errors ): int {
if ( is_int( $value ) ) {
return $value;
}
if ( is_string( $value ) && preg_match( '/^-?\d+$/', $value ) ) {
return (int) $value;
}
$errors->add( 'invalid_' . $field, sprintf( '%s must be an integer.', $field ) );
return 0;
}
private static function to_string( $value, string $field, WP_Error $errors ): string {
if ( is_string( $value ) ) {
return $value;
}
if ( is_scalar( $value ) ) {
return (string) $value;
}
$errors->add( 'invalid_' . $field, sprintf( '%s must be a string.', $field ) );
return '';
}
}
Use references/native-dto-patterns.md for
the full pattern: floats, booleans, DateTime, nested DTO collections, secrets,
with(), and PHP 8.1 variants.
Hydration rules
- One entry point. Use
from_array() or named factories like from_post( WP_Post $post ), from_option( array $option ), from_request( WP_REST_Request $request ).
- Sanitize at the input boundary, validate in/near the DTO. For
$_POST, use wp_unslash() first, then a field-specific sanitizer.
- Use allowlists. Read only known keys. Do not keep unknown keys unless the DTO has a deliberate
extra property.
- Use
array_key_exists() when null is meaningful. isset( $data['expires_at'] ) treats explicit null as absent.
- Avoid
empty() for typed fields. empty( '0' ) and empty( 0 ) are true.
- Coerce deliberately.
(int) 'abc' becomes 0; (bool) 'false' becomes true. Reject surprising input with WP_Error.
- Nested arrays become nested DTOs. Do not leave
items as random arrays when the plugin expects item shape.
- No dynamic properties. PHP 8.2 deprecates them. Declare every property.
- Defaults are explicit. Every constructor argument has a sensible default or is nullable.
Sensitive fields
DTOs often carry API keys, tokens, customer emails, or internal notes.
- Keep secrets nullable: absence is
null, not an empty secret string.
- Do not include secrets in
to_array() unless the method name says so, e.g. to_private_array().
- Prefer a tiny value object for credentials; see references/native-dto-patterns.md.
- Let the presenter decide whether a field is redacted for REST/admin/export.
Critical rules
- DTO is immutable. No setters. Use
with() to produce a changed copy.
- DTO is not a repository. No
get_post_meta(), no update_option(), no $wpdb, no HTTP calls.
- DTO is not a presenter. No HTML, no escaping, no
wp_send_json(), no rest_ensure_response().
- Hydration is explicit and allowlisted. Never
foreach ( $data as $key => $value ) { $dto->$key = $value; }.
- Validation returns
WP_Error or throws only for programmer errors. User input failures are normal and reportable.
- Use field-specific coercion. Do not use unchecked
(int), (bool), intval, settype, or empty()-driven normalization.
- Keep parameter/property names stable. Renaming
created_at to createdAt is a breaking change unless every caller and presenter is updated.
- For PHP 8.1 enums, use
tryFrom() and handle null. For PHP 7.4-compatible plugins, use constants plus explicit in_array( ..., true ).
Common mistakes
// WRONG - unchecked casts hide bad input.
$dto = new ProductDto( (int) $_POST['id'], (float) $_POST['price'] );
// WRONG - dynamic property hydration.
foreach ( $row as $key => $value ) {
$dto->{$key} = $value;
}
// WRONG - empty() rejects valid values.
if ( empty( $data['quantity'] ) ) {
return new WP_Error( 'missing_quantity', 'Quantity is required.' );
}
// WRONG - leaking secrets by dumping all object properties.
return get_object_vars( $dto );
Cross-references
- Run
wp-plugin-presenter when converting this DTO into REST/admin/JS/email output.
- Run
wp-plugin-architecture when deciding folder placement, namespaces, or by-feature vs by-type organization.
- Run
wp-plugin-options-storage when the DTO represents an option payload.
- Run
bd-data-object only if the project intentionally uses the better-data library. better-data automates many of these rules; this skill is the native no-library version.
What this skill does NOT cover
- HTML templates and escaping strategy after presentation.
- Database schema migrations or custom table repositories.
- better-data attributes, sources, sinks, or presenter builder APIs.
- REST route registration; use the REST-specific plugin skill if present.
References
1---2name: wp-plugin-dto3description: Design and review native DTOs in WordPress plugins without requiring better-data - immutable data carriers, explicit from_array hydration, strict coercion instead of unchecked casts, WP_Error validation failures, sensitive-field discipline, nested DTO arrays, and clear separation from repositories, WP models, presenters, REST controllers, and HTML views. Use when a plugin introduces FooDto, request DTOs, settings DTOs, value objects, admin-row data shapes, REST response source objects, or when reviewing code that passes raw arrays, stdClass, WP_Post, WC_Order, $_POST, post meta, or option arrays through multiple layers. Mentions better-data only as an optional higher-level library; this skill is for native implementations.4---56# WordPress plugin: native DTOs78For plugin code that moves structured data between request input, options,9meta, custom tables, external APIs, REST controllers, admin screens, cron jobs,10and presenters. A DTO gives that data one named shape instead of leaking raw11arrays across the plugin.1213This skill is intentionally **better-data-free**. If the project already uses14better-data, run `bd-data-object`; otherwise use this native pattern.1516## When to load references1718- Need a complete PHP 7.4 / PHP 8.1 DTO implementation, nested DTO, enum-like value set, or secret value object: read [references/native-dto-patterns.md](references/native-dto-patterns.md).19- Refactoring a controller/repository that currently passes raw arrays, `$_POST`, `WP_Post`, meta arrays, or option arrays around: read [references/before-after-raw-array.md](references/before-after-raw-array.md).2021## Misconception this skill corrects2223> "A DTO is just an array with nicer comments."2425Wrong direction. A DTO is a small immutable boundary object with a named schema,26explicit defaults, and one hydration path. It prevents common AI mistakes:27unchecked `(int)` casts, `empty()` swallowing `0`, `isset()` hiding intentional28`null`, dynamic properties, leaking secrets through `to_array()`, and passing29`$_POST` or raw post meta directly to business logic.3031## When to use this skill3233Trigger when ANY of the following is true:3435- Introducing `FooDto`, `FooData`, `SettingsData`, `RequestData`, `Payload`, or a value object.36- A repository, REST controller, admin page, cron job, or WooCommerce hook currently passes large associative arrays around.37- Data comes from `$_GET`, `$_POST`, REST params, options, post meta, user meta, term meta, custom tables, `WP_Post`, `WP_User`, or WooCommerce objects.38- Reviewing hydration / normalization code with casts like `(int)`, `(bool)`, `intval`, `settype`, `empty`, `isset`, `get_object_vars`, or dynamic property assignment.39- Presenter or REST response code needs a stable input object.4041## Layer boundaries4243| Layer | Responsibility |44|---|---|45| Source / repository | Read WP objects, options, meta, request params, API responses. Knows WordPress. |46| DTO | Hold normalized values. No DB writes, no HTML, no hooks, no global reads. |47| Validator | Decide whether the DTO is acceptable. Returns `true` or `WP_Error`. |48| Presenter | Convert DTO to arrays / JSON-ready values for REST, admin, JS config, email. |49| View / template | Escape and echo HTML. |5051The DTO may contain small normalization helpers, but it should not call52`get_post_meta()`, `update_option()`, `wp_remote_get()`, `add_action()`, or echo53anything.5455## Minimal class shape5657Prefer PHP 7.4-compatible immutable objects unless the plugin has a higher58minimum PHP version. Use PHP 8.1 `readonly` only when the plugin declares PHP598.1+.6061```php62namespace MyPlugin\Dto;6364use WP_Error;6566if ( ! defined( 'ABSPATH' ) ) {67 exit;68}6970final class ProductDto {71 private int $id;72 private string $title;7374 public function __construct( int $id = 0, string $title = '' ) {75 $this->id = $id;76 $this->title = $title;77 }7879 /** @param array<string,mixed> $data @return self|WP_Error */80 public static function from_array( array $data ) {81 $errors = new WP_Error();82 $id = self::to_int( $data['id'] ?? 0, 'id', $errors );83 $title = self::to_string( $data['title'] ?? '', 'title', $errors );8485 if ( '' === $title ) {86 $errors->add( 'missing_title', 'Product title is required.' );87 }88 if ( $errors->has_errors() ) {89 return $errors;90 }9192 return new self( $id, $title );93 }9495 /** @return array<string,mixed> */96 public function to_array(): array {97 return array(98 'id' => $this->id,99 'title' => $this->title,100 );101 }102103 public function id(): int { return $this->id; }104 public function title(): string { return $this->title; }105106 private static function to_int( $value, string $field, WP_Error $errors ): int {107 if ( is_int( $value ) ) {108 return $value;109 }110 if ( is_string( $value ) && preg_match( '/^-?\d+$/', $value ) ) {111 return (int) $value;112 }113 $errors->add( 'invalid_' . $field, sprintf( '%s must be an integer.', $field ) );114 return 0;115 }116117 private static function to_string( $value, string $field, WP_Error $errors ): string {118 if ( is_string( $value ) ) {119 return $value;120 }121 if ( is_scalar( $value ) ) {122 return (string) $value;123 }124 $errors->add( 'invalid_' . $field, sprintf( '%s must be a string.', $field ) );125 return '';126 }127}128```129130Use [references/native-dto-patterns.md](references/native-dto-patterns.md) for131the full pattern: floats, booleans, DateTime, nested DTO collections, secrets,132`with()`, and PHP 8.1 variants.133134## Hydration rules135136- **One entry point.** Use `from_array()` or named factories like `from_post( WP_Post $post )`, `from_option( array $option )`, `from_request( WP_REST_Request $request )`.137- **Sanitize at the input boundary, validate in/near the DTO.** For `$_POST`, use `wp_unslash()` first, then a field-specific sanitizer.138- **Use allowlists.** Read only known keys. Do not keep unknown keys unless the DTO has a deliberate `extra` property.139- **Use `array_key_exists()` when `null` is meaningful.** `isset( $data['expires_at'] )` treats explicit `null` as absent.140- **Avoid `empty()` for typed fields.** `empty( '0' )` and `empty( 0 )` are true.141- **Coerce deliberately.** `(int) 'abc'` becomes `0`; `(bool) 'false'` becomes `true`. Reject surprising input with `WP_Error`.142- **Nested arrays become nested DTOs.** Do not leave `items` as random arrays when the plugin expects item shape.143- **No dynamic properties.** PHP 8.2 deprecates them. Declare every property.144- **Defaults are explicit.** Every constructor argument has a sensible default or is nullable.145146## Sensitive fields147148DTOs often carry API keys, tokens, customer emails, or internal notes.149150- Keep secrets nullable: absence is `null`, not an empty secret string.151- Do not include secrets in `to_array()` unless the method name says so, e.g. `to_private_array()`.152- Prefer a tiny value object for credentials; see [references/native-dto-patterns.md](references/native-dto-patterns.md#secret-value-object).153- Let the presenter decide whether a field is redacted for REST/admin/export.154155## Critical rules156157- **DTO is immutable.** No setters. Use `with()` to produce a changed copy.158- **DTO is not a repository.** No `get_post_meta()`, no `update_option()`, no `$wpdb`, no HTTP calls.159- **DTO is not a presenter.** No HTML, no escaping, no `wp_send_json()`, no `rest_ensure_response()`.160- **Hydration is explicit and allowlisted.** Never `foreach ( $data as $key => $value ) { $dto->$key = $value; }`.161- **Validation returns `WP_Error` or throws only for programmer errors.** User input failures are normal and reportable.162- **Use field-specific coercion.** Do not use unchecked `(int)`, `(bool)`, `intval`, `settype`, or `empty()`-driven normalization.163- **Keep parameter/property names stable.** Renaming `created_at` to `createdAt` is a breaking change unless every caller and presenter is updated.164- **For PHP 8.1 enums, use `tryFrom()` and handle null.** For PHP 7.4-compatible plugins, use constants plus explicit `in_array( ..., true )`.165166## Common mistakes167168```php169// WRONG - unchecked casts hide bad input.170$dto = new ProductDto( (int) $_POST['id'], (float) $_POST['price'] );171172// WRONG - dynamic property hydration.173foreach ( $row as $key => $value ) {174 $dto->{$key} = $value;175}176177// WRONG - empty() rejects valid values.178if ( empty( $data['quantity'] ) ) {179 return new WP_Error( 'missing_quantity', 'Quantity is required.' );180}181182// WRONG - leaking secrets by dumping all object properties.183return get_object_vars( $dto );184```185186## Cross-references187188- Run **`wp-plugin-presenter`** when converting this DTO into REST/admin/JS/email output.189- Run **`wp-plugin-architecture`** when deciding folder placement, namespaces, or by-feature vs by-type organization.190- Run **`wp-plugin-options-storage`** when the DTO represents an option payload.191- Run **`bd-data-object`** only if the project intentionally uses the better-data library. better-data automates many of these rules; this skill is the native no-library version.192193## What this skill does NOT cover194195- HTML templates and escaping strategy after presentation.196- Database schema migrations or custom table repositories.197- better-data attributes, sources, sinks, or presenter builder APIs.198- REST route registration; use the REST-specific plugin skill if present.199200## References201202- [references/native-dto-patterns.md](references/native-dto-patterns.md) - complete DTO implementation patterns.203- [references/before-after-raw-array.md](references/before-after-raw-array.md) - refactoring raw arrays into DTOs.204- WordPress input security: `wp_unslash()`, sanitization, validation, and escaping.205- `WP_Error` for user-input validation failures.206- Official documentation: <https://developer.wordpress.org/plugins/security/securing-input/>207- Official documentation: <https://developer.wordpress.org/plugins/security/validating-sanitizing-escaping/>208- Official documentation: <https://developer.wordpress.org/reference/classes/wp_error/>209- Official documentation: <https://developer.wordpress.org/reference/functions/wp_unslash/>