DEV Community

Cover image for Open Source WordPress Contribution: My July 2026 Recap
Faisal Ahammad
Faisal Ahammad

Posted on • Originally published at faisalahammad.com

Open Source WordPress Contribution: My July 2026 Recap

July felt different from the start. I opened the month with a WooCommerce logging bug and closed it 24 pull requests later, spread across 9 different WordPress repositories. Some fixes took ten minutes. One took a full week of back and forth with a maintainer over a single line of code.

This post is my open source WordPress contribution recap for July 2026. I am sharing the actual code from each pull request, not just a list of links, so you can see what changed and why it mattered.


Fixing WooCommerce, One Bug at a Time

WooCommerce took up the biggest chunk of my month. I merged 10 pull requests into the core plugin, ranging from a one-line performance tweak to a new REST endpoint. I will walk through each one.

Log Cleanup Was Silently Leaving Files Behind

The month opened with PR #66073. LogHandlerFileV2::delete_logs_before_timestamp() fetched expired log files without setting a per_page value, so it inherited the admin UI default of 20. On sites with more than 20 expired log sources, the daily cleanup cron deleted only the first 20 and left the rest sitting in wp-content/uploads/wc-logs/ forever.

The fix batches the deletion instead of trusting a single page:

// BEFORE:
$files = $this->file_controller->get_files( [ 'before' => $timestamp ] );
foreach ( $files as $file ) {
    $this->delete_log_file( $file );
}

// AFTER (batched with a no-progress guard):
do {
    $files = $this->file_controller->get_files( [
        'before'   => $timestamp,
        'per_page' => 100,
    ] );
    $deleted_this_pass = 0;
    foreach ( $files as $file ) {
        if ( $this->delete_log_file( $file ) ) {
            $deleted_this_pass++;
        }
    }
} while ( $files && $deleted_this_pass > 0 );
Enter fullscreen mode Exit fullscreen mode

A site with 101 leftover log files now gets all of them removed instead of just 20. I added a no-progress guard so the loop stops if a batch cannot be deleted, which prevents an infinite loop on a stuck file.

The Order List Table Cache Ignored Custom Filters

PR #66207 fixed a subtler bug. ListTable::prepare_items() decides whether to skip SQL_CALC_FOUND_ROWS by checking the query args before any filters run. If a developer hooked woocommerce_order_list_table_prepare_items_query_args to add a meta_query, the cache fast path stayed blind to it and returned a stale total.

// BEFORE (checked pre-filter args):
if ( empty( array_diff( array_keys( $this->order_query_args ), $safe_keys ) ) ) {
    $args['no_found_rows'] = true;
}

// AFTER (checks post-filter args):
if ( empty( array_diff( array_keys( $order_query_args ), $safe_keys ) ) ) {
    $args['no_found_rows'] = true;
}
Enter fullscreen mode Exit fullscreen mode

Moving the check to run after the filter means any key a plugin adds correctly disables the cache shortcut. Small change, but it stopped merchants from seeing wrong order counts whenever a custom query filter was active.

Combining Six Requests Into One

The most involved WooCommerce PR of the month was PR #66276. Every wp-admin page load fired 6 separate wc-analytics REST requests just to populate the Activity Panel bell icon and the "Things to do next" homescreen widget. Three endpoints, each called twice by two different components, with no shared cache between them.

I added a single combined endpoint instead:

class ActivityPanelCounts extends \WC_REST_Data_Controller {
    protected $namespace = 'wc-analytics';
    protected $rest_base = 'activity-panel/counts';

    public function get_counts( $request ) {
        return rest_ensure_response( [
            'orders_to_fulfill_count'   => $this->get_count_via( '/wc-analytics/orders', [
                'page'     => 1,
                'per_page' => 1,
                'status'   => $request->get_param( 'order_statuses' ),
                '_fields'  => [ 'id' ],
            ] ),
            'reviews_to_moderate_count' => $this->get_count_via( '/wc-analytics/products/reviews', [
                'page'     => 1,
                'per_page' => 1,
                'status'   => $request->get_param( 'review_status' ),
            ] ),
        ] );
    }
}
Enter fullscreen mode Exit fullscreen mode

A matching activityPanelStore selector in @woocommerce/data means all three Activity Panel components now read from one place. The resolution cache collapses six network requests into one per page load, and nothing about the counting logic changed since the new endpoint just delegates to the existing ones internally.

If you want the fuller backstory on how I approach WooCommerce internals like this, my June 2026 open source recap covers a similar REST endpoint consolidation from the month before.

Guest Orders Finally Show a Name

PR #66279 fixed something that bugged store owners for a while. The WooCommerce Home Orders panel shows "Order #123 Customer Name" for actionable orders, but guest checkouts have no customer_id to look up, so the name always came back blank.

// BEFORE: only checked the registered customer record
const customerName = order.customer ? order.customer.name : '';

// AFTER: falls back to billing details for guest orders
const customerName = order.customer
    ? order.customer.name
    : [ order.billing?.first_name, order.billing?.last_name ]
          .filter( Boolean )
          .join( ' ' );
Enter fullscreen mode Exit fullscreen mode

The order response already carried the billing address. I just added billing to the requested fields and used it as a fallback. Registered customers still get their name linked to their profile, guests just show as plain text like the rest of the row.

Product Taxonomy Boxes Hidden by Default

PR #65990 addressed a first-run annoyance. On a user's first visit to Appearance → Menus, WordPress hides every meta box except Pages, Posts, Custom Links, and Categories. That default list swallowed the Product Categories, Product Tags, and Brands boxes too, so new users had to dig into Screen Options before they could add these to a menu.

public function filter_default_nav_menu_hidden_meta_boxes( $result, $option, $user ) {
    global $wp_meta_boxes;

    if ( false !== $result || ! $user || ! isset( $wp_meta_boxes['nav-menus'] ) ) {
        return $result;
    }

    $visible = [
        'add-post-type-page', 'add-post-type-post', 'add-custom-links',
        'add-category', 'add-product_cat', 'add-product_tag',
        'woocommerce_endpoints_nav_link',
    ];

    if ( taxonomy_exists( 'product_brand' ) ) {
        $visible[] = 'add-product_brand';
    }

    $hidden = [];
    foreach ( $wp_meta_boxes['nav-menus'] as $priorities ) {
        foreach ( (array) $priorities as $boxes ) {
            foreach ( (array) $boxes as $box ) {
                if ( isset( $box['id'] ) && ! in_array( $box['id'], $visible, true ) ) {
                    $hidden[] = $box['id'];
                }
            }
        }
    }

    return $hidden;
}
Enter fullscreen mode Exit fullscreen mode

This hooks get_user_option_metaboxhidden_nav-menus so it only fires when a user has no saved preference yet. Existing users with a saved Screen Options choice are never touched.

A Cache Poisoning Bug in the Brand Nav Widget

PR #65947 was the trickiest bug to trace this month. The Brand Nav widget's filter_out_cats() method hooks woocommerce_product_subcategories_args and returns an empty taxonomy when a brand filter is active in the URL. That empty-taxonomy query returns zero rows, and WooCommerce cached those zero rows under the same key used by regular, non-filtered requests. Every visitor after that, brand filter or not, read the poisoned cache and saw no subcategories at all.

// BEFORE: always cached the result, even an empty taxonomy query
wp_cache_set( $cache_key, $result, 'product_cat' );

// AFTER: only cache when the query actually resolved to a taxonomy
if ( ! empty( $args['taxonomy'] ) ) {
    wp_cache_set( $cache_key, $result, 'product_cat' );
}
Enter fullscreen mode Exit fullscreen mode

An empty-taxonomy query never produces a meaningful result, so storing it just poisoned the shared cache for no benefit. Skipping the cache write when taxonomy is empty fixed it for every visitor, not just the one who triggered the brand filter.

Nine Default Colors for Visual Attributes

PR #65923 added a small quality-of-life feature. When a merchant creates a new "Color / image" attribute, the terms list starts empty and they have to add each color by hand. This seeds 9 common colors automatically:

private static function get_default_color_terms(): array {
    return [
        'black' => [ 'label' => __( 'Black', 'woocommerce' ), 'color' => '#121212' ],
        'white' => [ 'label' => __( 'White', 'woocommerce' ), 'color' => '#FFFFFF' ],
        'red'   => [ 'label' => __( 'Red', 'woocommerce' ), 'color' => '#D32F2F' ],
        'blue'  => [ 'label' => __( 'Blue', 'woocommerce' ), 'color' => '#1976D2' ],
        'green' => [ 'label' => __( 'Green', 'woocommerce' ), 'color' => '#388E3C' ],
        // gray, yellow, pink, and brown follow the same pattern
    ];
}
Enter fullscreen mode Exit fullscreen mode

The seeder only runs from WC_Admin_Attributes::process_add_attribute(), so it fires when a merchant creates an attribute through the UI, not through programmatic wc_create_attribute() calls or CSV imports. That distinction mattered during review since nobody wants a bulk import silently injecting terms nobody asked for.

Rounding Out the Month: Layout, Cron Status, and jQuery Cleanup

Three smaller WooCommerce fixes closed out the batch:

  • PR #66280 fixed missing layout styles on the product_brand_thumbnails_description shortcode. Its stylesheet never defined list-style: none or a clearfix, so the shortcode rendered as a bare bulleted list instead of a grid. I also clamped the columns argument with max( 1, absint( $args['columns'] ) ) so an invalid value like columns="abc" cannot throw a DivisionByZeroError.
  • PR #66188 fixed a false "Not scheduled" message on the WooCommerce Status page. The Daily Cron check looked for the old wp_next_scheduled('wc_admin_daily') hook, but that migrated to Action Scheduler as wc_admin_daily_wrapper a while back. Swapping the check to as_next_scheduled_action('wc_admin_daily_wrapper') fixed the false alarm on fresh installs.
  • PR #66273 replaced deprecated jQuery .focus() shorthand calls with .trigger( focus ) across six legacy JS files, clearing the JQMIGRATE: jQuery.fn.focus() event shorthand is deprecated warning that showed up on classic checkout.

If you want to try submitting a fix like these yourself, the WooCommerce Contributing Guidelines walk through the coding standards and PR process the core team expects.


Security Fixes for LifterLMS

LifterLMS gave me 7 merged pull requests in July, and most of them came from running the WordPress Plugin Check tool against the plugin and working through what it flagged.

Two Open Redirect Fixes for the Same Bug

PR #3201 and PR #3209 both replaced wp_redirect() with wp_safe_redirect() in the template loader and the lesson progression controller. I submitted the fix twice, once through a direct patch and once through a scan-driven pass, and both landed with the identical change:

// BEFORE:
if ( $redirect ) {
    nocache_headers();
    wp_redirect( $redirect );
    exit;
}

// AFTER:
if ( $redirect ) {
    nocache_headers();
    wp_safe_redirect( $redirect );
    exit;
}
Enter fullscreen mode Exit fullscreen mode

The $redirect value can come from a filter, which means a third-party plugin could pass in an untrusted URL. wp_safe_redirect() blocks external hosts by default, closing off that open redirect path. The lesson-completion redirect got the same treatment since it also passes through a filter before use.

Fifteen Files Missing Direct Access Protection

PR #3198 added the standard defined( 'ABSPATH' ) || exit; guard to 15 PHP files that Plugin Check flagged, mostly builder view templates:

// BEFORE:
<?php
/**
 * Builder lesson model view
 */
?>
<script type="text/html" id="tmpl-llms-lesson-template">

// AFTER:
<?php
/**
 * Builder lesson model view
 */
defined( 'ABSPATH' ) || exit;
?>
<script type="text/html" id="tmpl-llms-lesson-template">
Enter fullscreen mode Exit fullscreen mode

Two files that Plugin Check flagged already had an equivalent guard, so I left those alone. The fix is mechanical, but it closes off direct HTTP access to files that were never meant to run outside the WordPress bootstrap.

A Stale Cache Broke Media Protection

PR #3194 fixed a bug that only showed up with a persistent object cache like Object Cache Pro active. The authorization result for a protected file gets cached with wp_cache_add(), which only writes when the key does not already exist. If someone viewed an unprotected file first, the 'null' sentinel for "not protected" stayed in cache forever, even after the file was later protected.

// BEFORE:
wp_cache_add( $cache_key, 'null', 'llms_media_authorization', $cache_expiration );

// AFTER:
wp_cache_set( $cache_key, 'null', 'llms_media_authorization', $cache_expiration );
Enter fullscreen mode Exit fullscreen mode

Switching to wp_cache_set() means the cache always reflects the current state. I also added an invalidate_authorization_cache() method and hooked it into the protection-save flow so the cache clears the moment a file's protection status changes.

A Mislabeled File in the Media Library

PR #3181 fixed a labeling bug. When a file was protected through the File block's lock icon, the Media Library attachment screen showed an add-on's label ("protected as an assignment submission") instead of the core label, because the add-on's filter ran for every protected file regardless of who protected it.

$auth_filter        = $protector->get_authorization_filter_name( $post->ID );
$is_addon_protected = $auth_filter && 'llms_attachment_is_access_allowed' !== $auth_filter;
$is_core_protected  = $protector->is_media_protected( $post->ID ) && ! $is_addon_protected;

if ( $is_core_protected ) {
    return $form_fields;
}
Enter fullscreen mode Exit fullscreen mode

Core-protected files now return early with the correct label before the add-on filter ever runs. Add-ons still get to label their own files, since the check compares the actual authorization hook name instead of just checking a boolean.

Small Fixes: Voucher Width and Button Styling

PR #3236 widened the "Uses" input on the voucher edit screen from 50px to 80px, since 4-digit redemption counts like 1000 were getting clipped. PR #3237 added the wp-element-button CSS class to the access plan button so it correctly inherits theme button styling from theme.json on block themes, while leaving classic themes untouched.

Open redirects are a well documented attack pattern outside WordPress too. The OWASP Unvalidated Redirects and Forwards Cheat Sheet explains why letting user input control a redirect target is risky in any web application, not just WordPress plugins.


Teaching Plugin Check to Name Names

My single pull request to the official WordPress Plugin Check tool, the same scanner behind the Plugin Check plugin on WordPress.org, fixed a usability gap in how it validates the Requires Plugins header. Before this change, a plugin declaring more than one dependency got one generic "header is not valid" error with no hint about which slug was the actual problem.

private function check_requires_plugins_header( $result, $requires_plugins, $label, $main_file ) {
    $slugs = array_map( 'trim', explode( ',', $requires_plugins ) );

    foreach ( $slugs as $slug ) {
        if ( '' === $slug ) {
            continue;
        }

        if ( ! preg_match( '/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug ) ) {
            // reports an error naming this specific slug
            continue;
        }

        $this->check_requires_plugins_slug_status( $result, $slug, $main_file );
    }
}
Enter fullscreen mode Exit fullscreen mode

Each malformed slug now gets its own error naming that exact slug. Valid slugs get checked against the WordPress.org plugin directory through a transient-cached API call, and a warning appears if a declared dependency cannot be found there. During review, the maintainer pointed out that checking a plugin's local install state was not this check's job. A plugin_repo category check should validate whether a plugin is ready for the directory, so I dropped the original local-state approach entirely in favor of the directory lookup.


Yoast SEO: When a Passing Score Was Still Wrong

Yoast SEO PR #23466 fixed a scoring bug that had been quietly dragging down otherwise perfect SEO analyses. The functionWordsInKeyphrase assessment correctly returns an empty result when a focus keyphrase contains real content words, so it stays hidden from the UI. But the main SEO and taxonomy assessors still counted that empty row in the overall score denominator, which dropped a fully green analysis from around 99 down to about 94.

// BEFORE:
this.assessor = new SEOScoreAggregator();

// AFTER:
this.assessor = new ValidOnlyResultsScoreAggregator();
Enter fullscreen mode Exit fullscreen mode

The related-keyphrase and collection-page assessors already used ValidOnlyResultsScoreAggregator, which skips results without a real score. Switching the main SEO and taxonomy assessors to the same aggregator brought them in line, and every subclass built on top (cornerstone content, product pages, store blog posts) inherited the fix automatically.

The official Yoast developer documentation has more detail on how the SEO analysis pipeline scores individual assessments if you want to dig into the aggregator pattern further.


Ultimate Member: A Redirect That Broke Every Page

Ultimate Member PR #1833 fixed a bug that redirected logged-out visitors to the homepage on every single post, but only on sites running Spectra block themes. The root cause took some digging. Ultimate Member's filter_protected_posts() hooks the_posts, which fires on every WP_Query, not just the main one. During block template resolution, Spectra runs a secondary query for the UM "User" page, and that page's access settings force a redirect. The redirect logic did not check whether it was running on the main query, so it exited the entire request on a background query meant only for template resolution.

// BEFORE: exits on any query, including secondary ones
exit( wp_redirect( esc_url_raw( add_query_arg( 'redirect_to', urlencode_deep( $curr ), um_get_core_page( 'login' ) ) ) ) );

// AFTER: only exits on the main query
if ( is_object( $query ) && $query instanceof WP_Query &&
     ( $query->is_main_query() || ! empty( $query->query_vars['um_main_query'] ) ) ) {
    exit( wp_redirect( esc_url_raw( add_query_arg( 'redirect_to', urlencode_deep( $curr ), um_get_core_page( 'login' ) ) ) ) );
} else {
    $filtered_post = $this->maybe_replace_title( $post );
    $filtered_posts[] = apply_filters( 'um_access_restricted_post_instance', $filtered_post, $post, $query );
}
Enter fullscreen mode Exit fullscreen mode

Secondary queries still apply content restriction through title replacement and post hiding, they just no longer call exit() and kill the page. Direct visits to a genuinely restricted page still redirect correctly, since the main-query check preserves that behavior.


Gutenberg: Bringing Back the Phone-Shaped Preview

Gutenberg PR #80271 restored something editors had lost a while back. Mobile and tablet previews in the post editor used to show a phone or tablet shaped frame. A prior change deprecated the old useResizeCanvas hook, which had injected a fixed device height, and replaced it with a width-only model. The width was correct after that change, but the height just collapsed to fill the editor.

// New private selector deriving device-shaped preview height
function getCanvasHeight( state ) {
    const width = getCanvasWidth( state );
    const ratios = { mobile: 8 / 5, tablet: 4 / 3 };
    const device = getDeviceForWidth( width );

    if ( ! device || ! ratios[ device ] ) {
        return undefined;
    }

    return Math.round( width * ratios[ device ] );
}
Enter fullscreen mode Exit fullscreen mode

Mobile gets an 8:5 portrait ratio and tablet gets 4:3, both matching how the editor looked before the regression. The height only applies at the exact preset width set by the Preview dropdown, so dragging the frame to a custom width frees it to fill the editor again. Desktop preview and the Site Editor's separate resizable frame are untouched.


ElasticPress: Cleaning Up Translator Comments

ElasticPress PR #4323 fixed two warnings that showed up when running wp i18n make-pot. One string had two conflicting translator comments attached to it, and one placeholder had no comment at all.

// BEFORE: two different comments for the same string literal
/* translators: %1$s: first feature name, %2$s: second feature name */
__( '%1$s and %2$s', 'elasticpress' );
// ... elsewhere in the file:
/* translators: %1$s: comma-separated list of feature names, %2$s: last feature name */
__( '%1$s and %2$s', 'elasticpress' );

// AFTER: one consistent comment for both call sites
/* translators: %1$s: feature name(s), %2$s: last feature name */
__( '%1$s and %2$s', 'elasticpress' );
Enter fullscreen mode Exit fullscreen mode

I also added a missing /* translators: %d: Page number. */ comment above a pagination string. Translator comments do not affect runtime behavior, but a clean POT file matters a lot to anyone localizing the plugin, since a missing or duplicate comment leaves translators guessing what a placeholder actually represents.


Pods Framework: Getting Ahead of PHP 8.5

Pods PR #7550 was the largest single-purpose change of the month by file count. PHP 8.5 deprecates the (boolean) cast, and it becomes a fatal error in PHP 9.0. I swapped every occurrence to the canonical (bool) form across 25 files, 139 occurrences total.

// BEFORE:
$params->single = (boolean) $params->single;

// AFTER:
$params->single = (bool) $params->single;
Enter fullscreen mode Exit fullscreen mode

(bool) has been valid PHP since the language's earliest versions, so this is a pure one-to-one swap with zero behavior change. No new tests were needed since the runtime result is identical either way. I grepped the full repository afterward to confirm zero remaining (boolean) occurrences.

The full list of casts and functions PHP is phasing out lives in the official PHP migration guide, which is worth a scan if you maintain a plugin that has not been updated in a while.


WP Rocket: A Tiny Schema Fix With a Real Impact

WP Rocket PR #8583 was the smallest diff of the month, just one line, but it fixed a real WP-CLI compatibility issue. The wp-rocket/get-recommendations ability declared its input schema as [ 'type' => 'null' ], which caused WP-CLI's ability command to reject it outright with "input must be an object, null given."

// BEFORE:
'input_schema' => [
    'type' => 'null',
],

// AFTER:
'input_schema' => [],
Enter fullscreen mode Exit fullscreen mode

An empty array represents "no input properties" in JSON Schema terms and is what WP-CLI expects for an ability that takes no input. One line, but it unblocked WP-CLI from running the ability at all.


What I Learned This Month

Twenty-four pull requests across nine plugins taught me a few things worth writing down.

Security fixes cluster once you start looking. Three separate plugins this month had open redirect or direct-access issues that Plugin Check flagged. Once you run the scan on one plugin, you start spotting the same pattern everywhere else. That is not a coincidence, it is what happens when a whole ecosystem grows organically over a decade.

Cache bugs are the hardest to find and the easiest to miss in review. The Brand Nav cache poisoning issue and the LifterLMS media authorization cache bug both needed a persistent object cache active to reproduce. A plugin can look completely correct in testing and still poison a shared cache key for every visitor on a production site with Redis or Object Cache Pro running.

Duplicate work happens, and that is fine. I submitted the exact same LifterLMS redirect fix through two separate pull requests, and both got merged with identical code. Open source is not always a perfectly coordinated pipeline. Small overlaps happen, and maintainers handle them without much drama.

Small PRs still need real testing. The WP Rocket one-liner and the ElasticPress translator comment fix both look trivial. Both still needed a clear repro, a clear fix, and clear test steps, because a maintainer reviewing a "small" PR still needs to trust that it actually works.


Looking Ahead

July pushed me across more repositories than any month so far, from a REST endpoint that cuts admin requests by 6x to a one-line JSON Schema fix that unblocks WP-CLI. Every plugin here is used by thousands or millions of WordPress sites, and each fix, no matter how small, chips away at friction someone else was living with.

If any of these bugs sound familiar from your own site, that is exactly the kind of thing worth reporting on a plugin's issue tracker. You do not need to write the fix yourself. Flagging the problem clearly is often the hardest part.

If you liked this format, take a look at my May 2026 open source recap and my Co-Authors Plus contributions write-up for more real code from past months.

For the official rules WordPress.org uses to review plugins, the Plugin Check plugin page is the best place to start if you want to run these same checks against your own plugin.

Top comments (0)