A debugging story about WooCommerce Subscriptions, caching red herrings, and one deliberate line of code that quietly broke a conversion funnel.
The Symptom
A client's fitness membership site offers a 7-day free trial on its subscription products. Simple enough: sign up, browse the workout library free for a week, get charged automatically after — standard WooCommerce Subscriptions behavior.
Except guests visiting the site couldn't see the trial at all. The product page just showed the regular monthly price. No trial messaging, no "$0 due today." And when a guest actually went through checkout, they were charged the full price immediately — no 7-day grace period in sight.
Logged-in users saw the trial just fine. Every time.
That single fact — logged in: works, logged out: doesn't — turned out to be both the biggest clue and the biggest trap of the entire investigation.
First Theories (All Wrong)
The obvious first guess: maybe this particular product's trial configuration was broken. A quick look at the product's variations showed the trial length was set correctly — 7 days, right there in the admin. So that wasn't it.
Next theory: maybe it was a caching problem. The site ran multiple cache layers — a page cache, a reverse proxy cache, and a persistent object cache. Guest requests are cached far more aggressively than logged-in ones, so a stale cached copy of the page (generated before some earlier fix) could easily explain "guests see the old broken version, logged-in users always get a fresh page."
This felt promising enough to chase hard:
- Purged the page cache. No change.
- Purged the reverse-proxy cache. No change.
- Flushed the persistent object cache directly via WP-CLI. No change.
- Tested with cache-busting query strings and
Cache-Control: no-cacheheaders, confirmed via response headers that the request was a genuine cache MISS, generated fresh, at that exact moment. Still no trial for the guest request.
That last test was important. It proved, with certainty, that this wasn't caching at all. PHP was executing fully, fresh, for an anonymous visitor — and still choosing not to show the trial. Whatever was happening, it was happening in application logic, not in a cache layer.
Ruling Out an Entire Stack, One Piece at a Time
With caching off the table, the search moved into the code itself. Over the following days:
- Product-level theories (duplicate variations, a "Virtual" checkbox left unchecked, a product-type mismatch between simple vs. variable subscriptions) were tested and eliminated — the same behavior showed up across every subscription product on the site, not just one.
-
Custom code — theme files, must-use plugins, active Code Snippets — was searched exhaustively via
grepfor anything referencing trial length, price display, or login state. All clean. - WooCommerce Dynamic Pricing, a plugin capable of role-based price rules, was a strong suspect for a while. Ruled out by testing as a plain logged-in Customer account (no special role) — the trial showed correctly, meaning it wasn't about which role you had, only whether you were logged in at all.
- A subscription "Enhancer" plugin with its own trial-limiting logic looked like the smoking gun for a moment. Tracing its actual function line-by-line showed it explicitly left guests untouched — it only affected logged-in users checking for repeat trials. Not the cause.
- WooCommerce Subscriptions' own core code was traced end-to-end: the function that reads a product's trial length, the function that turns that into the "with a 7-day free trial" string, the method that assembles the final price HTML. No login check anywhere in any of it.
By this point, every obvious suspect — and several non-obvious ones — had been checked and cleared. The trial data was correct. The display logic was correct. And yet, for a guest, the number was still coming out as zero.
Following the Data, Not the Guesses
The turning point was switching from "search the files" to "ask WordPress directly what's actually happening at runtime."
WP-CLI made this possible:
wp eval 'echo WC_Subscriptions_Product::get_trial_length(11343);'
Run as an anonymous request (user ID 0, matching a real guest), this returned 0. Run with an authenticated user context, it correctly returned 7. Confirmed, cleanly: the discrepancy was real, reproducible, and happening inside this exact function call.
That function's actual implementation was almost embarrassingly simple:
public static function get_trial_length( $product ) {
return apply_filters( 'woocommerce_subscriptions_product_trial_length', self::get_meta_data(...), ... );
}
It reads the correct value from the database (7, confirmed directly via wp post meta get) — then passes it through a WordPress filter before returning it. Somewhere, something was hooked onto that filter and changing the number.
A plain-text search for that filter name across the plugin and theme files found only two matches: the definition itself, and one plugin's filter (already cleared above). Nothing else — which didn't make sense, because something was clearly modifying the value.
The missing piece was that custom code snippets aren't stored as files at all — they live in the database, injected into the request at runtime. No filesystem search will ever find them.
The real breakthrough came from asking WordPress's own hook registry directly:
wp eval 'global $wp_filter;
foreach ($wp_filter["woocommerce_subscriptions_product_trial_length"]->callbacks as $priority => $callbacks) {
foreach ($callbacks as $cb) {
echo "Priority $priority: " . (is_array($cb["function"])
? get_class($cb["function"][0]) . "::" . $cb["function"][1]
: "Closure") . "\n";
}
}'
This listed every single callback actually hooked onto that filter, in execution order — including a Closure, running before everything else, that no static search had caught. Using PHP's reflection API to ask that closure where it was defined pointed straight at a database-stored code snippet.
The Actual Bug
The snippet in question, named "Blocks Multiple Free Trials," was a perfectly reasonable piece of anti-abuse logic: don't let a customer claim a second free trial on a product they've already subscribed to before. Good instinct, sound feature.
But nested inside it was this:
if ( ! is_user_logged_in() ) {
return 0; // Stronger enforcement: require login for any trial
}
Not a bug in the traditional sense — a deliberate design decision, with a comment explaining exactly what it did. Someone had decided that requiring a login before offering any trial was a form of "stronger enforcement." It technically worked as written. It just wasn't what the business actually wanted: real customers were landing on the site from social media, seeing a product that advertised a 7-day trial, and getting charged in full the moment they checked out as a guest.
The Fix
The fix didn't touch the legitimate repeat-trial protection at all — only the guest-blocking branch:
if ( ! is_user_logged_in() ) {
return $trial_length; // Guests get the trial; repeat-check only applies to logged-in users
}
Guests now see the trial correctly, first-time visitors get $0-today checkout with the first charge seven days later, and returning customers still can't double-dip on a second trial. One line changed; the intended behavior preserved.
What Made This One Hard
A few things stack up to make a bug like this genuinely difficult, worth naming for anyone hitting something similar:
- The correlation was real but the causation was wrong. "Logged in vs. logged out" was a completely accurate description of the symptom the entire time — it just pointed everyone toward login-related plugin settings and eligibility logic, when the actual cause was a single conditional buried three layers deep in unrelated anti-abuse code.
- Every individual system was innocent. WooCommerce core, the theme, the caching layers, the pricing plugin — each one, checked in isolation, was completely correct. The bug lived in custom glue code that no one else's system could see or account for.
-
Code Snippets don't show up in a filesystem search. Anything stored and evaluated from the database is invisible to
grep, and easy to forget existed at all if it isn't the snippet you're actively thinking about. - A deliberate decision looks identical to a bug from the outside. The code wasn't broken; it was doing exactly what it was told. The mismatch was between what the code said and what the business actually wanted — which no amount of debugging tools can catch unless you're willing to trace all the way down to the literal returned value and ask "why."
Takeaways
- When behavior differs by user state, don't assume it's a permissions/eligibility setting — check for custom filters hooked into the same pipeline, especially in code you didn't write yourself.
-
grepis powerful, but it can't see what's stored in a database. If a site has any kind of snippet manager, check its actual contents directly — not just "how many snippets are active" in an admin list. - When static analysis stalls, ask the running application directly.
wp evaland inspecting$wp_filterat runtime found in minutes what days of file searching couldn't. - Not every "bug" is broken code. Sometimes it's a correct implementation of an incorrect assumption — and the fix is a conversation about intent, not just a patch.
Top comments (0)