WordPress wp_parse_args(): Merge Function Arguments with Defaults and Recursive Deep Merge

wp_parse_args() is WordPress’s utility for merging a set of user-provided arguments with a set of defaults, similar to JavaScript’s Object.assign({}, defaults, userArgs) or PHP’s array_merge($defaults, $user_args). The key difference from a plain array_merge() call is that wp_parse_args() accepts the first argument as either an array or a URL query string (or an object), converting it to an array before merging — making it flexible for functions that want to accept arguments in multiple formats. WordPress itself uses wp_parse_args() extensively in WP_Query, wp_list_comments(), register_post_type(), and virtually every function that accepts an options array. For deep merging of nested arrays, a recursive variant is needed — wp_parse_args() only merges the top level, which is a common source of bugs when defaults contain nested arrays.

Problem: A custom widget function accepts an $args parameter that can be an array, a query string, or an object. The function needs sensible defaults for all unspecified keys, must not error on missing keys, and needs a recursive variant for one nested sub-array ('query_args') that itself has defaults.

Solution: Use wp_parse_args() for the top-level merge. For the nested query_args sub-array, write a recursive helper or use wp_parse_args() a second time on the nested value.

<?php
// ── Basic usage ───────────────────────────────────────────────────────
function render_post_widget( $args = [] ): void {
    $defaults = [
        'title'       => __( 'Latest Posts', 'textdomain' ),
        'count'       => 5,
        'show_date'   => true,
        'show_thumb'  => false,
        'query_args'  => [
            'post_type'      => 'post',
            'posts_per_page' => 5,
            'orderby'        => 'date',
            'order'          => 'DESC',
        ],
    ];

    // wp_parse_args accepts: array, URL query string, or stdClass object
    $args = wp_parse_args( $args, $defaults );

    // ── Nested array requires a second wp_parse_args call ─────────────
    // wp_parse_args only merges TOP level — nested arrays are replaced
    // if partially specified. Fix by merging the nested array separately:
    $query_defaults    = $defaults['query_args'];
    $args['query_args'] = wp_parse_args( $args['query_args'], $query_defaults );

    // Now safe to access all keys:
    $query = new WP_Query( $args['query_args'] );
    echo '<div class="post-widget">';
    if ( $args['title'] ) {
        echo '<h3>' . esc_html( $args['title'] ) . '</h3>';
    }
    while ( $query->have_posts() ) {
        $query->the_post();
        echo '<p><a href="' . esc_url( get_permalink() ) . '">' . esc_html( get_the_title() ) . '</a></p>';
    }
    wp_reset_postdata();
    echo '</div>';
}

// ── Calling with different argument types ──────────────────────────────
render_post_widget( [ 'count' => 3, 'show_thumb' => true ] );
render_post_widget( 'count=3&show_date=false' );  // URL query string OK
$obj = new stdClass(); $obj->count = 3;
render_post_widget( $obj );                        // object OK

// ── Recursive deep merge helper ───────────────────────────────────────
// Use when defaults and user args both have nested arrays at multiple levels
function wp_parse_args_recursive( array $args, array $defaults ): array {
    $result = $defaults;
    foreach ( $args as $key => $value ) {
        if ( is_array( $value ) && isset( $result[ $key ] ) && is_array( $result[ $key ] ) ) {
            $result[ $key ] = wp_parse_args_recursive( $value, $result[ $key ] );
        } else {
            $result[ $key ] = $value;
        }
    }
    return $result;
}

// ── wp_parse_args vs array_merge: key difference ─────────────────────
$defaults = [ 'a' => 1, 'b' => 2 ];
$args     = [ 'b' => 99 ];

// array_merge: numeric keys from $args overwrite those from $defaults
// (same for string keys, no special handling of objects/query strings)
array_merge( $defaults, $args ); // ['a' => 1, 'b' => 99] ✓

// wp_parse_args: same result for arrays, but also handles query strings + objects
wp_parse_args( $args, $defaults ); // ['a' => 1, 'b' => 99] ✓
wp_parse_args( 'b=99', $defaults ); // ['a' => 1, 'b' => '99'] ✓ (string values)

NOTE: wp_parse_args() is a shallow merge — if a default has a nested array and the caller passes a partial nested array, wp_parse_args() replaces the entire nested array rather than merging it. This is a frequent source of bugs when writing functions with nested default arrays (like query_args). The recursive helper wp_parse_args_recursive() shown above solves this. When accepting query string arguments ('count=3&show_date=0'), values are always strings after parsing — 0 becomes the string '0' rather than integer 0 or boolean false. Always cast values to the expected type after merging: (int) $args['count'], (bool) $args['show_date']. This is why most WordPress core code uses arrays rather than query strings for internal function arguments, even though wp_parse_args() supports both.