close
Skip to content

Writing more robust configuration/args in plugins #489

Description

@bph

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 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.

Proposed titles

  • "Writing More Robust Configuration in Plugins"
  • "Config Objects and Renderers: A Better Way to Structure Plugin Arguments"
  • "Splitting Data from Behavior: Rethinking How Plugins Render Their Own Options"

Code example

// 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' ) ] );

Outline

  1. The pattern everyone writes — the reflexive procedural version: an options array, wp_parse_args(), done. Muscle memory, not a strawman.
  2. Where one function starts to strain — not one dramatic bug, but the ordinary cost of a single function owning everything: deciding labels, building markup, and being the only thing that can be read, reused, or tested. The 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.
  3. Splitting data from behavior — introduce a Config object (immutable, per-key defaults, backed by a small enum for the translatable labels) and a Renderer object (turns config into markup) as two collaborators instead of one function.
  4. Function vs. class, side by side — what actually changes: not just "fewer bugs," but data separated from behavior.
  5. What you gain — a Config that can be read, passed around, or reused without dragging the renderer along; Renderer methods that are individually extensible or testable; a shape that's checked at the boundary; autocomplete and rename-refactor that work. The wp_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.
  6. What it costs — two classes instead of one function, more ceremony, less "WordPress-native," a real learning curve for developers fluent in hooks and filters but not deep OOP.
  7. The honest gap: OOP doesn't grant extensibility for free — a renderer with one giant method is the same monolith wearing a class. The seams have to be designed on purpose.
  8. When it's actually worth it — a decision frame, not a mandate: reuse across contexts, real nested structure, or public API surface → the split earns it. Flat options local to one function → it doesn't.

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:

  1. Data and behavior stop being one function — a renderer's pieces can be read, extended, or tested independently of the config, and vice versa.
  2. The shape is checked where it enters the object, not discovered later wherever the wrong-typed value happens to get used.
  3. Translatable defaults become possible at all, not just tidier__() 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.
  4. A nice side effect, not the point: the classic 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:

  • Nested value types still aren't checked. array $labels doesn't stop someone from passing an int where a string belongs — the outer shape is checked, the inner shape is still a promise.
  • A class doesn't grant extensibility by itself. Without a deliberate seam (a protected method, a hook), a Renderer is just the old monolithic function with a class keyword in front of it.
  • It's real, ongoing maintenance surface for a feature that might never need it. This is the piece's central caution: know which of the two problems you actually have before reaching for either tool.

Metadata

Metadata

Assignees

Type

No type

Projects

Status
To-do

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions