Every plugin developer writes this function without thinking about it: an options array, wp_parse_args(), and then one function that does everything from there — decides the labels, builds the markup, echoes it. It's one of the first patterns most of us learn, and it holds up fine for a long time.
This article uses a small, ordinary front-end feature — a call-to-action banner — to show what changes when that one function becomes two collaborating objects instead: an immutable Config that owns the data and its shape, and a Renderer that owns turning that data into markup. The split is really about separating data from behavior, not about chasing a bug — extending or testing either half stops requiring touching the other. One good side effect falls out of it almost for free: because the config resolves its label defaults per key instead of merging one array wholesale, the classic wp_parse_args() gotcha — override one nested option, silently lose its siblings — stops being possible. That's a bonus, not the headline. Along the way the piece also touches a small, real PHP detail — translatable label defaults can't live in a class constant, since __() isn't a constant expression — which is why this reaches for a small backing enum instead of a literal array. It's not a pitch for OOP-everywhere; the piece is explicit about what the split costs and where a plain function is still the right call.
// The everyday pattern: one function does everything — merge args,
// decide labels, build the markup, echo it.
function myplugin_render_cta( $args = [] ) {
$args = wp_parse_args( $args, [
'heading' => __( 'Try it free', 'myplugin' ),
'labels' => [
'button' => __( 'Get Started', 'myplugin' ),
'dismiss' => __( 'No thanks', 'myplugin' ),
],
] );
// ...build and echo the banner markup from $args.
}
// Splitting that into two collaborators instead: a Config that owns the
// data, and a Renderer that owns turning it into markup. (Translatable
// label defaults live behind a small enum, not a literal array — __()
// isn't a constant expression, so it can't sit in a class constant.)
enum CtaBannerLabel: string
{
case Heading = 'heading';
case Button = 'button';
case Dismiss = 'dismiss';
public function text(): string
{
return match ( $this ) {
self::Heading => __( 'Try it free', 'myplugin' ),
self::Button => __( 'Get Started', 'myplugin' ),
self::Dismiss => __( 'No thanks', 'myplugin' ),
};
}
}
final class CtaBannerConfig
{
/**
* @param array<string, string> $labels
*/
public function __construct(
public readonly string $description = '',
public readonly string $buttonUrl = '',
public readonly string $style = 'primary',
public readonly bool $dismissible = true,
private readonly array $labels = []
) {}
public function label( CtaBannerLabel $key ): string
{
return $this->labels[ $key->value ] ?? $key->text();
}
}
final class CtaBannerRenderer
{
public function __construct( private readonly CtaBannerConfig $config ) {}
public function render(): string
{
return sprintf(
'<div class="cta-banner cta-banner--%s">%s%s</div>',
esc_attr( $this->config->style ),
$this->renderHeading(),
$this->renderButton()
);
}
private function renderHeading(): string
{
return sprintf( '<h2>%s</h2>', esc_html( $this->config->label( CtaBannerLabel::Heading ) ) );
}
private function renderButton(): string
{
return sprintf(
'<a class="cta-banner__button" href="%s">%s</a>',
esc_url( $this->config->buttonUrl ),
esc_html( $this->config->label( CtaBannerLabel::Button ) )
);
}
}
// A bonus, not the point: overriding just the button label still leaves
// 'dismiss' resolving to its default, since labels resolve per key.
new CtaBannerConfig( labels: [ 'button' => __( 'Start Now', 'myplugin' ) ] );
Honest estimate: most single-purpose render functions in a typical plugin — probably 70–80% of what a plugin actually ships — are genuinely fine as wp_parse_args() forever. Nothing in this article argues otherwise. The pattern is for the other 20–30%: config that gets read in more than one place, has real nested structure (per-item labels, overrides), or is public surface other themes/plugins call directly.
Discussed in #488
Originally posted by justintadlock August 4, 2026
Description
Every plugin developer writes this function without thinking about it: an options array,
wp_parse_args(), and then one function that does everything from there — decides the labels, builds the markup, echoes it. It's one of the first patterns most of us learn, and it holds up fine for a long time.This article uses a small, ordinary front-end feature — a call-to-action banner — to show what changes when that one function becomes two collaborating objects instead: an immutable
Configthat owns the data and its shape, and aRendererthat owns turning that data into markup. The split is really about separating data from behavior, not about chasing a bug — extending or testing either half stops requiring touching the other. One good side effect falls out of it almost for free: because the config resolves its label defaults per key instead of merging one array wholesale, the classicwp_parse_args()gotcha — override one nested option, silently lose its siblings — stops being possible. That's a bonus, not the headline. Along the way the piece also touches a small, real PHP detail — translatable label defaults can't live in a class constant, since__()isn't a constant expression — which is why this reaches for a small backing enum instead of a literal array. It's not a pitch for OOP-everywhere; the piece is explicit about what the split costs and where a plain function is still the right call.Proposed titles
Code example
Outline
wp_parse_args(), done. Muscle memory, not a strawman.wp_parse_args()partial-override gotcha (override one nested option, silently lose its siblings) gets one honest mention here — real and well-known, but a side note, not the argument.Configobject (immutable, per-key defaults, backed by a small enum for the translatable labels) and aRendererobject (turns config into markup) as two collaborators instead of one function.Configthat can be read, passed around, or reused without dragging the renderer along;Renderermethods that are individually extensible or testable; a shape that's checked at the boundary; autocomplete and rename-refactor that work. Thewp_parse_args()merge bug also just stops happening, as a side effect of how defaults resolve — worth a mention, not a selling point on its own.What does this get you?
Honest estimate: most single-purpose render functions in a typical plugin — probably 70–80% of what a plugin actually ships — are genuinely fine as
wp_parse_args()forever. Nothing in this article argues otherwise. The pattern is for the other 20–30%: config that gets read in more than one place, has real nested structure (per-item labels, overrides), or is public surface other themes/plugins call directly.Key wins, and they're real regardless of scale:
__()isn't a constant expression, so it can't live in a class constant or a parameter default. A small backing enum with a method is the actual mechanism, and it's a detail most WordPress developers haven't had a reason to run into yet.wp_parse_args()partial-override bug stops being possible, since defaults resolve per key instead of getting merged wholesale. Worth a mention in the piece — not the reason to make the split.array_replace_recursive()is also a fix.What doesn't come free, and the article says so directly:
array $labelsdoesn't stop someone from passing an int where a string belongs — the outer shape is checked, the inner shape is still a promise.Rendereris just the old monolithic function with aclasskeyword in front of it.