Presenter Overview
BetterData\Presenter\Presenter projects a DataObject into a context-specific output shape — REST JSON, admin list rows, email bodies, CSV lines.
use BetterData\Presenter\PresentationContext;
use BetterData\Presenter\Presenter;
$json = Presenter::for($product)
->context(PresentationContext::rest())
->only(['id', 'title', 'price', 'priceFormatted'])
->compute('priceFormatted', fn ($p) => wc_price($p->price))
->rename('post_title', 'title')
->toJson();
Entry points
Presenter::for(DataObject $dto): Presenter
Presenter::forCollection(iterable $dtos): CollectionPresenter
forCollection replays the configuration over every DTO in the iterable. Same fluent methods, same terminal toArray() / toJson().
Fluent methods
All return self for chaining.
Context
.context(PresentationContext $context)— sets the active context (REST / admin / email / custom). See PresentationContext.
Field selection
.only(array $fields, bool $strict = false)— whitelist.strict: truethrowsUnknownFieldExceptionon typos (must be a property name or computed field name)..hide(string|array $field, ?callable $when = null)— hide field(s); optional predicatefn(PresentationContext) => bool..hideUnlessCan(string $field, string $capability, ...$args)— shorthand forhide($field, fn($ctx) => !$ctx->userCan($cap, ...$args))..showOnlyFor(string $field, array $roles)— shorthand: hide unless current user has one of the listed roles.
Field shaping
.rename(string|array $from, ?string $to = null)— rename output keys. Single:rename('post_title', 'title'). Bulk:rename(['post_title' => 'title', 'post_name' => 'slug'])..compute(string $name, Closure $factory)— add or override a field with a computed value. Closure:fn(DataObject $dto, PresentationContext $ctx): mixed. Lazy — only invoked if the field survivesonly()/hide()filters..preset(string $field, Closure $renderer)— override the rendering of a nested field. Closure:fn(mixed $value, PresentationContext $ctx): mixed.
Sensitive / Secret
.includeSensitive(array $fields)— opt-in whitelist of#[Sensitive]-marked fields to include in output. Even when included,Secret-typed values still redact to'***'. To reveal aSecret, do it inside acomputeclosure.
Formatting
.formatDate(string $field, string $format, ?string $as = null)— formatDateTimeInterfaceviaDateTimeFormatter(locale + timezone aware). If$asis null, replaces the field; otherwise creates a new field under$as..formatCurrency(string $field, ?string $as = null, ?string $currency = null, bool $html = false)— format numeric viaCurrencyFormatter(WooCommerce-aware when available, plain fallback otherwise).$html: truewraps in WC HTML markup.
Terminal methods
.toArray(): array— serialize to plain associative array.toJson(?int $flags = null): string— JSON-encode the array. Always forcesJSON_THROW_ON_ERROR. Default flags:JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES.
CollectionPresenter exposes the same terminals, returning list<array> and a JSON-encoded list respectively.
Operation order in toArray()
1. Collect DTO public properties
2. Apply per-field `preset` renderers (if registered)
3. Recurse on nested DataObject / arrays of DataObject
4. Merge `compute` closures (computed fields can override properties)
5. Redact #[Sensitive] fields (unless in includeSensitive whitelist)
6. Redact Secret-typed fields (always — only `Secret::reveal()` in compute escapes)
7. Apply `only` whitelist
8. Apply `hide` predicates
9. Apply `rename` map
↓
10. Result: array
toJson() adds the encode step on top.
Subclassing
For reusable configurations, subclass and override configure():
final class ProductPresenter extends Presenter
{
protected function configure(): void
{
$this
->rename('post_title', 'title')
->compute('formattedPrice', fn ($p) => wc_price($p->price))
->hideUnlessCan('cost', 'manage_woocommerce');
}
}
ProductPresenter::for($product)
->context(PresentationContext::rest())
->toArray();
configure() runs during construction. Fluent calls after construction can still override or add to the configuration.
Common mistakes
- Calling
Secret::reveal()outside acomputeclosure expecting the rendered output —toArray()always redactsSecretto'***'. Reveal only inside a closure where the call site is auditable. - Using
only()and forgetting to include a computed field name — computed fields count as fields;only(['id', 'title'])excludes acompute('formattedPrice', ...)field. - Setting
rename('a', 'x')andrename('b', 'x')— collision throwsLogicException. - Expecting
formatDateto mutate the original field — by default it does (replaces); passas: 'displayDate'to keep the original and add a new field.