<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vicente G. Reyes</title>
    <description>The latest articles on DEV Community by Vicente G. Reyes (@highcenburg).</description>
    <link>https://dev.to/highcenburg</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F126345%2F84bad9a2-d302-4943-8934-6c27a497daa1.png</url>
      <title>DEV Community: Vicente G. Reyes</title>
      <link>https://dev.to/highcenburg</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/highcenburg"/>
    <language>en</language>
    <item>
      <title>The Bug That Threw No Errors: How a Repeat-Trial Guard Silently Killed Our Free Trial Funnel</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Fri, 14 Aug 2026 12:06:09 +0000</pubDate>
      <link>https://dev.to/highcenburg/the-bug-that-threw-no-errors-how-a-repeat-trial-guard-silently-killed-our-free-trial-funnel-3l2h</link>
      <guid>https://dev.to/highcenburg/the-bug-that-threw-no-errors-how-a-repeat-trial-guard-silently-killed-our-free-trial-funnel-3l2h</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I'm the lead developer on &lt;strong&gt;Rev6.fit&lt;/strong&gt;, a fitness membership platform built on WordPress + WooCommerce Subscriptions + LearnDash. It runs 49 plugins, five membership families, and a stack of custom code snippets — the kind of production environment where every change has a blast radius.&lt;/p&gt;

&lt;p&gt;Rev6's entire top-of-funnel is a &lt;strong&gt;7-day free trial&lt;/strong&gt;. Every consumer membership — All-Access, On-Demand, Vitality — leads with "$0 today, first charge in 7 days." If the trial disappears, new signups don't slow down. They stop.&lt;/p&gt;

&lt;p&gt;And one day, it disappeared. For every prospect. Silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;Logged-out visitors — which is to say, &lt;strong&gt;every single potential new customer&lt;/strong&gt; — landed on membership product pages and saw... a full-price subscription. No "with a 7-day free trial" label. No "$0 today." Just the sticker price.&lt;/p&gt;

&lt;p&gt;Existing members saw everything correctly. The client saw everything correctly (logged in, obviously). The site threw zero errors. Sentry — which we run on both the PHP and JS sides — was green. PageSpeed was fine. Checkout worked.&lt;/p&gt;

&lt;p&gt;The funnel was just quietly bleeding out.&lt;/p&gt;

&lt;p&gt;The catch didn't come from a dashboard. It came from a human: &lt;strong&gt;Avie&lt;/strong&gt;, my primary client contact, noticed the trial messaging was gone from the membership pages and flagged it. That detail matters to the story — with all our monitoring green, the alerting system that actually fired was someone who knows the site well enough to feel when something's off. Every production stack has one of those. They're underrated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The root cause
&lt;/h2&gt;

&lt;p&gt;Here's the part I love, because it's such a classic shape: &lt;strong&gt;the bug was inside a guard written for a different problem.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The site carried a custom snippet whose job was to enforce &lt;strong&gt;one free trial per customer&lt;/strong&gt; — if you'd already consumed your trial, the trial offer was stripped from the subscription for you. Reasonable policy for any trial-led membership business.&lt;/p&gt;

&lt;p&gt;The right logic is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Has &lt;em&gt;this user&lt;/em&gt; already had a trial? If yes, remove the trial."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But the snippet carried an &lt;strong&gt;extra condition&lt;/strong&gt; that effectively turned it into:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Can I &lt;em&gt;confirm&lt;/em&gt; this user deserves a trial? If not, remove the trial."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And for a logged-out visitor, there's no user to check. No account → no trial history → can't confirm eligibility → &lt;strong&gt;strip the trial&lt;/strong&gt;. The guard failed &lt;em&gt;closed&lt;/em&gt; against the exact people it should have failed &lt;em&gt;open&lt;/em&gt; for: first-time visitors who haven't created an account yet.&lt;/p&gt;

&lt;p&gt;A simplified reconstruction of the shape (the real snippet stays private, but this is the logic faithfully):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The repeat-trial guard — intended behavior&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'woocommerce_subscriptions_product_trial_length'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$trial_length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_current_user_id&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// ❌ The extra condition: guests have no $user_id,&lt;/span&gt;
    &lt;span class="c1"&gt;// so they fell into the "no trial" branch too&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$user_id&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;rev6_user_has_used_trial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$user_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// no trial for you&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$trial_length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The subtle trap: on Rev6, &lt;strong&gt;guest checkout is disabled&lt;/strong&gt; — accounts are auto-created at signup. So a guest seeing the trial offer is completely safe; by the time money and trial entitlement are involved, they &lt;em&gt;have&lt;/em&gt; an account and the repeat-trial check can do its job. The guest branch in the guard wasn't just wrong — it was protecting against a scenario the platform's own architecture already made impossible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The fix was almost anticlimactic — remove the guest condition, keep the repeat-trial protection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$user_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$trial_length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ✅ guests see the trial — accounts are created at signup anyway&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;rev6_user_has_used_trial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$user_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// repeat-trial protection stays intact&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$trial_length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One condition. That's the whole diff. But the verification checklist was the real work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ Logged-out visitor sees "with a 7-day free trial" on every membership product&lt;/li&gt;
&lt;li&gt;✅ Checkout shows &lt;strong&gt;$0 today / first charge in 7 days&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;✅ Account auto-creation at signup still works&lt;/li&gt;
&lt;li&gt;✅ A user who already consumed a trial still gets &lt;strong&gt;no&lt;/strong&gt; second trial&lt;/li&gt;
&lt;li&gt;✅ Verified on staging first, then live
The last item on that list matters most: it would have been very easy to "fix" this by deleting the guard entirely — and quietly reopen the repeat-trial loophole the snippet existed to close. Smashing a bug shouldn't resurrect its predecessor.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Sentry taught me by &lt;em&gt;not&lt;/em&gt; catching this
&lt;/h2&gt;

&lt;p&gt;Rev6 runs Sentry on both PHP and JS, and it's caught real production issues for us — an orphaned marketing-automation job erroring every 60 seconds, broken script dependencies on product pages, a Stripe Express Checkout misconfiguration. It's earned its keep.&lt;/p&gt;

&lt;p&gt;But this bug was invisible to it, and that's the lesson I'd hand any dev working on revenue-critical flows:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error monitoring catches code that fails. It can't catch code that succeeds at doing the wrong thing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The trial guard executed perfectly. No exception, no warning, no log line. It returned &lt;code&gt;0&lt;/code&gt; exactly as written — the code was correct; the &lt;em&gt;condition&lt;/em&gt; was wrong. That's a whole class of bug that lives below the monitoring waterline: pricing logic, discount eligibility, visibility rules, permission gates. The stuff that decides whether money moves.&lt;/p&gt;

&lt;p&gt;My takeaways for that class of bug:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Test your money paths logged out.&lt;/strong&gt; Your default browsing state as a developer (logged in, admin bar on, caches primed) is the state your prospects will never be in. Incognito is a QA tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guards should fail open or closed &lt;em&gt;deliberately&lt;/em&gt;.&lt;/strong&gt; Every early-return in an eligibility check is a policy decision. Write the comment. "Guests: allow, because accounts are created at signup" would have made this bug impossible to write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Behavioral monitoring complements error monitoring.&lt;/strong&gt; An alert on "trial signups per day dropped to zero" would have caught this in hours — instead of waiting for a sharp-eyed human to happen across the page.
## The win&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The trial is back, the repeat-trial abuse protection still holds, and every first-time visitor to Rev6 now sees the offer that the entire funnel was designed around. No new plugins, no rewrite — one condition removed, with a verification list long enough to trust the change on a live revenue path.&lt;/p&gt;

&lt;p&gt;The bugs that throw exceptions are the easy ones. The legendary ones return &lt;code&gt;0&lt;/code&gt; and walk away clean.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Ice — freelance full-stack dev (Django/React + WordPress/WooCommerce + Shopify). More at &lt;a href="https://vicentereyes.org" rel="noopener noreferrer"&gt;vicentereyes.org&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>php</category>
      <category>wordpress</category>
    </item>
    <item>
      <title>The WebP Migration That Wasn't About WebP</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Fri, 31 Jul 2026 10:58:28 +0000</pubDate>
      <link>https://dev.to/highcenburg/the-webp-migration-that-wasnt-about-webp-3n90</link>
      <guid>https://dev.to/highcenburg/the-webp-migration-that-wasnt-about-webp-3n90</guid>
      <description>&lt;p&gt;Nobody asked me to do this.&lt;/p&gt;

&lt;p&gt;I maintain a WooCommerce store bolted to LearnDash courses on a Divi theme — eight years of content, a few thousand images. Page weight had been nagging at me for a while, so I put "convert the images to WebP" on my own list. No ticket, no client request, just a maintenance item I thought would take an afternoon.&lt;/p&gt;

&lt;p&gt;I almost installed ShortPixel and called it a day.&lt;/p&gt;

&lt;p&gt;Instead I spent twenty minutes auditing first, and found that WebP was maybe the fourth most important thing wrong with how that site served images. This is what the audit turned up, and why "just install an optimizer plugin" would have made things worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Always inventory before you optimize
&lt;/h2&gt;

&lt;p&gt;The site runs on a 4GB VPS that had already OOM-killed MySQL twice that quarter. Bulk image transcoding is CPU and memory heavy. So before touching anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;wp-content/uploads
&lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"JPEG:  &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="se"&gt;\(&lt;/span&gt; &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s1"&gt;'*.jpg'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s1"&gt;'*.jpeg'&lt;/span&gt; &lt;span class="se"&gt;\)&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"PNG:   &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s1"&gt;'*.png'&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"WebP:  &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s1"&gt;'*.webp'&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output stopped me cold:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2.1G    .
JPEG:  3245
PNG:   1443
WebP:  12301
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Twelve thousand WebP files on a site that supposedly had no WebP.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: 241MB of files nothing was serving
&lt;/h2&gt;

&lt;p&gt;The naming told the story — &lt;code&gt;image.jpg.webp&lt;/code&gt;, sidecar files in the same directory as the original. That's EWWW Image Optimizer's convention.&lt;/p&gt;

&lt;p&gt;EWWW was not installed. Somebody had run it, then removed the plugin.&lt;/p&gt;

&lt;p&gt;When EWWW generates sidecars, delivery depends on either an &lt;code&gt;.htaccess&lt;/code&gt; rewrite doing &lt;code&gt;Accept&lt;/code&gt;-header negotiation, or a &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; element filter. Both live in the plugin. Remove the plugin and the files become inert — 241MB of dead weight, still being scanned by the server's malware scanner on every pass, still counted in every backup.&lt;/p&gt;

&lt;p&gt;Verified they were genuinely unreferenced before deleting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# no content references&lt;/span&gt;
wp db query &lt;span class="s2"&gt;"SELECT COUNT(*) FROM wp_posts WHERE post_content LIKE '%.jpg.webp%';"&lt;/span&gt;
&lt;span class="c"&gt;# no attachment metadata references&lt;/span&gt;
wp db query &lt;span class="s2"&gt;"SELECT COUNT(*) FROM wp_postmeta WHERE meta_value LIKE '%.jpg.webp%';"&lt;/span&gt;
&lt;span class="c"&gt;# no rewrite rule anywhere&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-in&lt;/span&gt; webp .htaccess ~/conf/&lt;span class="k"&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three zeros. Deleted, 241MB back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; image optimizer plugins leave their output behind when uninstalled. If you inherit a site, grep the uploads directory for orphaned formats before you add another optimizer to the pile.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: the size ladder had been demolished
&lt;/h2&gt;

&lt;p&gt;This is the one that mattered.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s1"&gt;'print_r( get_intermediate_image_sizes() );'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;Array&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;large&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;gform&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;choice&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;sm&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;gform&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;choice&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;md&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;gform&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;choice&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;lg&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four registered sizes. No &lt;code&gt;thumbnail&lt;/code&gt;, no &lt;code&gt;medium&lt;/code&gt;, no &lt;code&gt;medium_large&lt;/code&gt;. And critically — no &lt;code&gt;woocommerce_thumbnail&lt;/code&gt;, no &lt;code&gt;woocommerce_gallery_thumbnail&lt;/code&gt;, none of Divi's.&lt;/p&gt;

&lt;p&gt;Look at the array keys. They're non-sequential: 3, 16, 17, 18. The array originally held 0 through 18 and something had &lt;code&gt;unset()&lt;/code&gt; entries without reindexing. That's a filter stripping sizes at runtime, not sizes that were never registered.&lt;/p&gt;

&lt;p&gt;The culprit was a plugin called &lt;code&gt;disable-thumbnails-and-threshold&lt;/code&gt;, hooking &lt;code&gt;intermediate_image_sizes&lt;/code&gt; at priority 100, with eighteen sizes checked off in its settings. A previous dev had installed it to fight disk pressure. Understandable instinct, catastrophic execution.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this is worse than it sounds
&lt;/h3&gt;

&lt;p&gt;WooCommerce still &lt;em&gt;believed&lt;/em&gt; it had a 300×300 thumbnail size:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s1"&gt;'print_r( wc_get_image_size("woocommerce_thumbnail") );'&lt;/span&gt;
&lt;span class="c"&gt;# =&amp;gt; width 300, height 300, crop 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The size was registered in WooCommerce's config, but the files were never generated. WooCommerce ships &lt;code&gt;WC_Regenerate_Images&lt;/code&gt;, which hooks &lt;code&gt;wp_get_attachment_image_src&lt;/code&gt; and resizes on the fly when a requested size is missing.&lt;/p&gt;

&lt;p&gt;I counted actual coverage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find wp-content/uploads &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="se"&gt;\(&lt;/span&gt; &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s1"&gt;'*-300x300.jpg'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s1"&gt;'*-300x300.png'&lt;/span&gt; &lt;span class="se"&gt;\)&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="c"&gt;# =&amp;gt; 141&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;141 files out of 2,216 attachments. Six percent coverage.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Meaning: for roughly 94% of product image requests, WooCommerce was doing an on-the-fly Imagick resize. On a box the host had already flagged for CPU saturation.&lt;/p&gt;

&lt;p&gt;And the &lt;code&gt;srcset&lt;/code&gt; consequence: with only &lt;code&gt;large&lt;/code&gt; (1024px) registered, every responsive image had exactly one candidate. A phone loading a product grid pulled 1024px files into 300px slots. WebP shaves ~30% off a file. Serving the right dimensions shaves 90%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The format was never the problem. The missing size ladder was.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 3: the images weren't the disk hog
&lt;/h2&gt;

&lt;p&gt;Adding up the raster formats: 705MB PNG + 242MB WebP + 192MB JPEG = 1,139MB. Total uploads: 2.1GB.&lt;/p&gt;

&lt;p&gt;Where was the other gigabyte?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-printf&lt;/span&gt; &lt;span class="s1"&gt;'%f\n'&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'NF&amp;gt;1{print tolower($NF)}'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;122 PDFs, 12 MP4s, 2 Keynote files. Course manuals and lesson videos — two videos alone were 170MB, and one training manual existed in four copies across two editions for 185MB.&lt;/p&gt;

&lt;p&gt;If I'd gone in with "let's reclaim disk by optimizing images," I'd have spent a day of CPU to reclaim less than the duplicate PDFs were wasting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: three filters, no new plugins
&lt;/h2&gt;

&lt;p&gt;I considered ShortPixel and Imagify. Both are good. Both were wrong here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;They'd add ~17,000 API credits of cost for 2,216 attachments across 8 sizes&lt;/li&gt;
&lt;li&gt;They write sidecar files and need a delivery mechanism — &lt;strong&gt;the exact pattern that left 241MB of orphans when EWWW was removed&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;They'd be plugin #50 on a site already carrying too many
WordPress core has done this natively since 6.x. Three snippets, no dependencies.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  1. Restore the size ladder
&lt;/h3&gt;

&lt;p&gt;Rather than deactivating the disable-thumbnails plugin — which would swing from "no sizes" to "all nineteen sizes" and regenerate every unused Divi variant — I unchecked exactly five and left thirteen stripped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$o&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'dtat_disablethumbnails_option_name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s1"&gt;'thumbnail'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'medium'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'medium_large'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'woocommerce_thumbnail'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'woocommerce_gallery_thumbnail'&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$k&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;unset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$o&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$k&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;update_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'dtat_disablethumbnails_option_name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$o&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine registered sizes instead of four or nineteen. The Divi &lt;code&gt;et-pb-*&lt;/code&gt; sizes stay stripped — the theme's own bloat wasn't worth reintroducing.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. WebP output for generated subsizes
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'image_editor_output_format'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$formats&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$filename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$mime_type&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$mime_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s1"&gt;'image/jpeg'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'image/png'&lt;/span&gt; &lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$formats&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$mime_type&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'image/webp'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$formats&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'wp_editor_set_quality'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$quality&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$mime_type&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'image/webp'&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$mime_type&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;82&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$quality&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The critical property here: WordPress preserves the source file as &lt;code&gt;original_image&lt;/code&gt; in attachment metadata. Every future regeneration encodes from the lossless master, not from the WebP. Verify it before you commit to a bulk run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s1"&gt;'$m = wp_get_attachment_metadata( 123 ); var_dump( $m["original_image"] ?? "ABSENT" );'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns &lt;code&gt;ABSENT&lt;/code&gt;, stop. You'd be setting up cumulative quality loss — every regeneration re-encoding lossy from lossy, degrading invisibly over months until it's baked into everything.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Rewrite hardcoded URLs at render time
&lt;/h3&gt;

&lt;p&gt;This is the gotcha that nearly shipped broken.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;image_editor_output_format&lt;/code&gt; governs what WordPress &lt;em&gt;generates&lt;/em&gt;. It does nothing about URLs already written into &lt;code&gt;post_content&lt;/code&gt;. A Gutenberg &lt;code&gt;wp-block-image&lt;/code&gt; figure has the path baked into the block markup. So does a Divi shortcode. Regeneration doesn't touch stored markup.&lt;/p&gt;

&lt;p&gt;I measured the exposure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;post_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wp_posts&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;post_status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'publish'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'draft'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'inherit'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;post_content&lt;/span&gt; &lt;span class="n"&gt;REGEXP&lt;/span&gt; &lt;span class="s1"&gt;'wp-content/uploads/[^"]+[.](png|jpe?g)'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;post_type&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;190 non-revision posts — including 25 Divi Library items and 3 Theme Builder layouts, which inject into many pages each. Left alone, most of the site's content images would have kept serving PNG while their WebP siblings sat unused on disk.&lt;/p&gt;

&lt;p&gt;Fixed at render time rather than with a database &lt;code&gt;search-replace&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'wp_content_img_tag'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$attachment_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nb"&gt;strpos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/wp-content/uploads/'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_get_upload_dir&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Original filename -&amp;gt; current primary file, for -scaled attachments.&lt;/span&gt;
    &lt;span class="nv"&gt;$orig_map&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$attachment_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$meta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_get_attachment_metadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$attachment_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'original_image'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'file'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$orig_map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'original_image'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_basename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'file'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="nv"&gt;$cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;preg_replace_callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'#(/wp-content/uploads/[^\s"\']+?)\.(png|jpe?g)#i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$m&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$orig_map&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;$cache&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$rel&lt;/span&gt;           &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nb"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'/wp-content/uploads'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="nv"&gt;$cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;file_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$dir&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'basedir'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$rel&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'.webp'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'.webp'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="nv"&gt;$base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_basename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$orig_map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$base&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$orig_map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$base&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="nv"&gt;$html&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The &lt;code&gt;-scaled&lt;/code&gt; trap
&lt;/h3&gt;

&lt;p&gt;My first version of that filter was ten lines shorter — just the extension swap and the&lt;br&gt;
&lt;code&gt;file_exists()&lt;/code&gt; guard. It tested clean and shipped, and then I found this in a Divi module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;".../DrEdythe_7.2024.png"&lt;/span&gt;
     &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;".../DrEdythe_7.2024-scaled.webp 1831w,
             .../DrEdythe_7.2024-215x300.webp 215w, ..."&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every &lt;code&gt;srcset&lt;/code&gt; candidate WebP. The &lt;code&gt;src&lt;/code&gt; still a 9.9MB PNG — the largest file on the site.&lt;/p&gt;

&lt;p&gt;WordPress scales any upload over &lt;code&gt;big_image_size_threshold&lt;/code&gt; (2560px by default) and makes the&lt;br&gt;
scaled copy the primary file, keeping the upload as &lt;code&gt;original_image&lt;/code&gt;. With WebP output enabled&lt;br&gt;
you get &lt;code&gt;foo-scaled.webp&lt;/code&gt; as primary and &lt;code&gt;foo.png&lt;/code&gt; as the preserved master. &lt;strong&gt;There is no&lt;br&gt;
&lt;code&gt;foo.webp&lt;/code&gt;.&lt;/strong&gt; So the naive swap looks for a file that will never exist, finds nothing, and&lt;br&gt;
leaves the PNG in place — silently, on exactly the biggest images you most wanted to fix.&lt;/p&gt;

&lt;p&gt;The fix is to stop guessing filenames. &lt;code&gt;wp_content_img_tag&lt;/code&gt; passes the attachment ID, so&lt;br&gt;
metadata can tell you the real current filename. Just remember &lt;code&gt;, 20, 3&lt;/code&gt; on the &lt;code&gt;add_filter&lt;/code&gt;&lt;br&gt;
call — without the argument count the ID never arrives and the branch is dead code.&lt;/p&gt;

&lt;p&gt;Why render-time over &lt;code&gt;wp search-replace&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No database mutation.&lt;/strong&gt; Rollback is deactivating a snippet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's conditional.&lt;/strong&gt; It only rewrites when a real target exists — a &lt;code&gt;.webp&lt;/code&gt; sibling on disk, or a primary file named in attachment metadata. A blind &lt;code&gt;.png&lt;/code&gt; → &lt;code&gt;.webp&lt;/code&gt; search-replace would have broken every reference to the ~54 icons that were correctly never converted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It covers everything.&lt;/strong&gt; Gutenberg blocks, Divi modules, classic editor content — all pass through &lt;code&gt;the_content&lt;/code&gt;.
The per-request static cache keeps &lt;code&gt;file_exists()&lt;/code&gt; negligible, and page caching absorbs the rest.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Running the regeneration
&lt;/h2&gt;

&lt;p&gt;The site had Regenerate Thumbnails installed with a perfectly good UI. I used WP-CLI anyway, for two reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disk visibility.&lt;/strong&gt; The plugin runs until done or until the volume fills. The box was at 84%. Batching from CLI let me check headroom between chunks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp post list &lt;span class="nt"&gt;--post_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;attachment &lt;span class="nt"&gt;--post_mime_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;image/jpeg,image/png &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ids &lt;span class="nt"&gt;--posts_per_page&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nt"&gt;-1&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/ids.txt
&lt;span class="nb"&gt;split&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; 200 /tmp/ids.txt /tmp/idb_

&lt;span class="k"&gt;for &lt;/span&gt;b &lt;span class="k"&gt;in&lt;/span&gt; /tmp/idb_&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;nice&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 19 wp media regenerate &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;--only-missing&lt;/span&gt; &lt;span class="nt"&gt;--yes&lt;/span&gt;
  &lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-sm&lt;/span&gt; wp-content/uploads
  free &lt;span class="nt"&gt;-m&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-2&lt;/span&gt;
  &lt;span class="nb"&gt;sleep &lt;/span&gt;30
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Action Scheduler.&lt;/strong&gt; The site had a chronic Action Scheduler backlog the host had already flagged as a CPU source. Admin-driven bulk tools queue work there. WP-CLI is synchronous and bypasses it entirely.&lt;/p&gt;

&lt;p&gt;I also blocked WooCommerce's own background regeneration for the duration, since re-registering &lt;code&gt;woocommerce_thumbnail&lt;/code&gt; can trigger it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'woocommerce_background_image_regeneration'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'__return_false'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;nice -n 19&lt;/code&gt; matters more than it looks. It means the kernel gives live traffic priority over the migration on every single scheduling decision. CPU sat around 69% during the run and the site stayed responsive, because the 69% was work nobody else wanted.&lt;/p&gt;

&lt;p&gt;Measured throughput: &lt;strong&gt;66 seconds per 200 attachments.&lt;/strong&gt; The full run took under twenty minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling it out to production
&lt;/h2&gt;

&lt;p&gt;Staging and production were separate applications on the same server. I wrote the whole thing up as a runbook and replayed it, and it went nearly identically — which is the point of rehearsing.&lt;/p&gt;

&lt;p&gt;Nearly. One thing bit me.&lt;/p&gt;

&lt;p&gt;The host gives each application a &lt;strong&gt;private &lt;code&gt;/tmp&lt;/code&gt; namespace&lt;/strong&gt;. My batch files, ID list, and log had all been written to &lt;code&gt;/tmp&lt;/code&gt; during the rehearsal, owned by the staging user. On production, every &lt;code&gt;/tmp&lt;/code&gt; write failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;bash: /tmp/ids.txt: Permission denied
rm: cannot remove '/tmp/idb_aa': Operation not permitted
split: /tmp/idb_aa: Permission denied
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Except the reads didn't fail. &lt;code&gt;wc -l /tmp/ids.txt&lt;/code&gt; cheerfully returned 2216 — &lt;strong&gt;staging's file&lt;/strong&gt;. My first production batch ran against the rehearsal environment's ID list. It happened to work, because the two databases share attachment IDs, but that was luck. The background script never started at all, and I only noticed because the log line count sat at zero.&lt;/p&gt;

&lt;p&gt;Moved everything to a private directory outside the web root and it ran clean.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson isn't "check permissions."&lt;/strong&gt; It's that a partially-failed setup is more dangerous than a fully-failed one. If &lt;code&gt;/tmp&lt;/code&gt; had been readable &lt;em&gt;and&lt;/em&gt; unwritable in a consistent way I'd have caught it instantly. Instead the writes failed loudly and the reads succeeded silently, and the silent success is what nearly shipped a run against the wrong data.&lt;/p&gt;

&lt;p&gt;Final numbers matched the rehearsal exactly: 2,097 of 2,216 converted, 55 sub-threshold icons untouched, 64 attachments whose files were already missing, and every oversized &lt;code&gt;-scaled&lt;/code&gt; image passing the URL-rewrite check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WooCommerce thumbnail coverage&lt;/td&gt;
&lt;td&gt;141 / 2,216 (6%)&lt;/td&gt;
&lt;td&gt;complete&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;srcset&lt;/code&gt; candidates per image&lt;/td&gt;
&lt;td&gt;1 (1024px)&lt;/td&gt;
&lt;td&gt;4 (100/150/300/768)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Attachments with WebP primary&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;2,097&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uploads directory&lt;/td&gt;
&lt;td&gt;~2,100MB&lt;/td&gt;
&lt;td&gt;~1,900MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sample: 1.33MB PNG hero&lt;/td&gt;
&lt;td&gt;1,332,273 B&lt;/td&gt;
&lt;td&gt;27,634 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sample: 322KB JPEG headshot&lt;/td&gt;
&lt;td&gt;322,419 B&lt;/td&gt;
&lt;td&gt;37,214 B&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The uploads directory got smaller.&lt;/strong&gt; Adding roughly 11,000 WebP files while deleting 12,283 orphaned sidecars nets out negative. Disk was never the constraint I'd braced for.&lt;/p&gt;

&lt;p&gt;The biggest win isn't in that table. It's that WooCommerce stopped resizing images on demand for 94% of product requests. That was real CPU on every uncached page load, and it's simply gone now.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's still wrong
&lt;/h2&gt;

&lt;p&gt;I'd have called this done, except the first Divi page I checked afterward served this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;".../DrEdythe_7.2024-scaled.webp"&lt;/span&gt;
     &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;".../DrEdythe_7.2024-scaled.webp 1831w,
             .../DrEdythe_7.2024-732x1024.webp 732w, ..."&lt;/span&gt;
     &lt;span class="na"&gt;sizes=&lt;/span&gt;&lt;span class="s"&gt;"(max-width: 1831px) 100vw, 1831px"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Correct format, correct &lt;code&gt;src&lt;/code&gt;, four &lt;code&gt;srcset&lt;/code&gt; candidates. And still wrong.&lt;/p&gt;

&lt;p&gt;Read the &lt;code&gt;sizes&lt;/code&gt; attribute. It tells the browser the image occupies the full viewport width up to 1831px. The module actually renders in a column a third of that. So the browser dutifully picks the 1831w candidate — 574KB — when the 732w file at 99KB would have covered the slot.&lt;/p&gt;

&lt;p&gt;Which is the same failure as the thumbnail ladder, wearing a different hat. I spent a session making sure the right files existed and the right URLs pointed at them, and the browser is still downloading 6× more than it needs because one attribute lies about the layout.&lt;/p&gt;

&lt;p&gt;That one's fixable through &lt;code&gt;wp_calculate_image_sizes&lt;/code&gt;, but it needs measuring against real module widths rather than guessed — and it's a bigger remaining win than the format change was.&lt;/p&gt;

&lt;p&gt;There's also a cleanup I deliberately didn't do. Superseded originals are still on disk: &lt;code&gt;DrEdythe_7.2024-732x1024.png&lt;/code&gt; at 1.36MB sitting inert next to its 99KB WebP, and hundreds more like it. Tempting to sweep. But while the render-time filter is doing delivery, those files &lt;em&gt;are&lt;/em&gt; the rollback path — deactivating the snippet reverts URLs to &lt;code&gt;.png&lt;/code&gt;, which only works if the &lt;code&gt;.png&lt;/code&gt; is still there. That deletion waits until WebP has been boring in production for a month.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell past-me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Audit before you optimize.&lt;/strong&gt; The task I'd written down for myself was "convert images to WebP." The actual problem was a stripped size ladder causing on-the-fly resizing and 3× mobile payloads. Twenty minutes of &lt;code&gt;find&lt;/code&gt; and &lt;code&gt;wp eval&lt;/code&gt; reordered the entire job — and turned a cosmetic improvement into a real one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Non-sequential array keys are a fingerprint.&lt;/strong&gt; &lt;code&gt;[3], [16], [17], [18]&lt;/code&gt; told me a filter was unsetting entries at runtime, not that sizes were never registered. That one detail pointed straight at the plugin responsible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check what the previous optimizer left behind.&lt;/strong&gt; Uninstalling an image plugin doesn't remove its output. Grep for orphaned formats before adding another.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;original_image&lt;/code&gt; is the check that prevents slow-motion disaster.&lt;/strong&gt; Without it you get lossy-from-lossy regeneration compounding over years, and nobody notices until every image on the site is soft.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the biggest images last, not first.&lt;/strong&gt; My URL-rewrite filter passed every test I wrote&lt;br&gt;
and still left the single largest file on the site unconverted, because &lt;code&gt;-scaled&lt;/code&gt; originals&lt;br&gt;
don't have the filename the naive swap looks for. Verify against your worst-case asset, not a&lt;br&gt;
representative one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A verification query can measure itself.&lt;/strong&gt; Those 64 missing files bothered me, so I wrote a&lt;br&gt;
check to find out whether anything referenced them. It reported 64 out of 64 referenced —&lt;br&gt;
alarming, and wrong. It was counting each attachment's own &lt;code&gt;_wp_attached_file&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;_wp_attachment_metadata&lt;/code&gt; rows as evidence that something else pointed at it, and matching a&lt;br&gt;
filename stem like &lt;code&gt;back&lt;/code&gt; against every post containing the word "back." Every row came back&lt;br&gt;
"used" because the query couldn't tell a reference from a reflection. Walking the actual pages&lt;br&gt;
in a browser had already given the right answer; I just trusted the SQL more because it had a&lt;br&gt;
number in it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generating the right sizes beats changing the format — and telling the browser the truth beats both.&lt;/strong&gt; WebP is maybe 30% off a file. Serving a 300px image instead of a 1024px one is 90%. And an honest &lt;code&gt;sizes&lt;/code&gt; attribute is what makes the browser actually choose the 300px one. Format, dimensions, declaration — in ascending order of how much they matter and descending order of how much attention they get.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Notes: written up from a real migration on a WooCommerce + LearnDash + Divi site I maintain. Proactive maintenance work, not a client request. Site details anonymized — swap in specifics if you have sign-off. Table prefix shown as &lt;code&gt;wp_&lt;/code&gt; throughout; use &lt;code&gt;$(wp db prefix)&lt;/code&gt; on real sites.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>woocommerce</category>
      <category>php</category>
    </item>
    <item>
      <title>How fast do you finish your usage on Claude Code?</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Wed, 29 Jul 2026 04:24:30 +0000</pubDate>
      <link>https://dev.to/highcenburg/how-fast-do-you-finish-your-usage-on-claude-code-4n76</link>
      <guid>https://dev.to/highcenburg/how-fast-do-you-finish-your-usage-on-claude-code-4n76</guid>
      <description>&lt;p&gt;On heavy coding work, I finish Claude's usage in more or less 2 hours. Is that fine or not? I'm on Claude Pro. How about you?&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>code</category>
    </item>
    <item>
      <title>The Case of the Disappearing Free Trial</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Fri, 24 Jul 2026 14:18:17 +0000</pubDate>
      <link>https://dev.to/highcenburg/the-case-of-the-disappearing-free-trial-26m2</link>
      <guid>https://dev.to/highcenburg/the-case-of-the-disappearing-free-trial-26m2</guid>
      <description>&lt;p&gt;&lt;em&gt;A debugging story about WooCommerce Subscriptions, caching red herrings, and one deliberate line of code that quietly broke a conversion funnel.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Symptom
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Logged-in users saw the trial just fine. Every time.&lt;/p&gt;

&lt;p&gt;That single fact — &lt;strong&gt;logged in: works, logged out: doesn't&lt;/strong&gt; — turned out to be both the biggest clue and the biggest trap of the entire investigation.&lt;/p&gt;

&lt;h2&gt;
  
  
  First Theories (All Wrong)
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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."&lt;/p&gt;

&lt;p&gt;This felt promising enough to chase hard:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Purged the page cache. No change.&lt;/li&gt;
&lt;li&gt;Purged the reverse-proxy cache. No change.&lt;/li&gt;
&lt;li&gt;Flushed the persistent object cache directly via WP-CLI. No change.&lt;/li&gt;
&lt;li&gt;Tested with cache-busting query strings and &lt;code&gt;Cache-Control: no-cache&lt;/code&gt; headers, confirmed via response headers that the request was a genuine cache &lt;strong&gt;MISS&lt;/strong&gt;, generated fresh, at that exact moment. &lt;strong&gt;Still no trial for the guest request.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ruling Out an Entire Stack, One Piece at a Time
&lt;/h2&gt;

&lt;p&gt;With caching off the table, the search moved into the code itself. Over the following days:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Product-level theories&lt;/strong&gt; (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 &lt;em&gt;every&lt;/em&gt; subscription product on the site, not just one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom code&lt;/strong&gt; — theme files, must-use plugins, active Code Snippets — was searched exhaustively via &lt;code&gt;grep&lt;/code&gt; for anything referencing trial length, price display, or login state. All clean.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WooCommerce Dynamic Pricing&lt;/strong&gt;, 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 &lt;em&gt;which&lt;/em&gt; role you had, only &lt;em&gt;whether&lt;/em&gt; you were logged in at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A subscription "Enhancer" plugin&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WooCommerce Subscriptions' own core code&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Following the Data, Not the Guesses
&lt;/h2&gt;

&lt;p&gt;The turning point was switching from "search the files" to "ask WordPress directly what's actually happening at runtime."&lt;/p&gt;

&lt;p&gt;WP-CLI made this possible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s1"&gt;'echo WC_Subscriptions_Product::get_trial_length(11343);'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run as an anonymous request (user ID 0, matching a real guest), this returned &lt;code&gt;0&lt;/code&gt;. Run with an authenticated user context, it correctly returned &lt;code&gt;7&lt;/code&gt;. Confirmed, cleanly: the discrepancy was real, reproducible, and happening inside this exact function call.&lt;/p&gt;

&lt;p&gt;That function's actual implementation was almost embarrassingly simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;get_trial_length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;apply_filters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'woocommerce_subscriptions_product_trial_length'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get_meta_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mf"&gt;...&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads the correct value from the database (&lt;code&gt;7&lt;/code&gt;, confirmed directly via &lt;code&gt;wp post meta get&lt;/code&gt;) — then passes it through a WordPress filter before returning it. Somewhere, something was hooked onto that filter and changing the number.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;something&lt;/em&gt; was clearly modifying the value.&lt;/p&gt;

&lt;p&gt;The missing piece was that &lt;strong&gt;custom code snippets aren't stored as files at all&lt;/strong&gt; — they live in the database, injected into the request at runtime. No filesystem search will ever find them.&lt;/p&gt;

&lt;p&gt;The real breakthrough came from asking WordPress's own hook registry directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s1"&gt;'global $wp_filter; 
foreach ($wp_filter["woocommerce_subscriptions_product_trial_length"]-&amp;gt;callbacks as $priority =&amp;gt; $callbacks) { 
    foreach ($callbacks as $cb) { 
        echo "Priority $priority: " . (is_array($cb["function"]) 
            ? get_class($cb["function"][0]) . "::" . $cb["function"][1] 
            : "Closure") . "\n"; 
    } 
}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This listed every single callback actually hooked onto that filter, in execution order — including a &lt;strong&gt;Closure&lt;/strong&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Actual Bug
&lt;/h2&gt;

&lt;p&gt;The snippet in question, named &lt;em&gt;"Blocks Multiple Free Trials,"&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;But nested inside it was this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nf"&gt;is_user_logged_in&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Stronger enforcement: require login for any trial&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;The fix didn't touch the legitimate repeat-trial protection at all — only the guest-blocking branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nf"&gt;is_user_logged_in&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$trial_length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Guests get the trial; repeat-check only applies to logged-in users&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Made This One Hard
&lt;/h2&gt;

&lt;p&gt;A few things stack up to make a bug like this genuinely difficult, worth naming for anyone hitting something similar:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The correlation was real but the causation was wrong.&lt;/strong&gt; "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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every individual system was innocent.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code Snippets don't show up in a filesystem search.&lt;/strong&gt; Anything stored and evaluated from the database is invisible to &lt;code&gt;grep&lt;/code&gt;, and easy to forget existed at all if it isn't the snippet you're actively thinking about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A deliberate decision looks identical to a bug from the outside.&lt;/strong&gt; 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."&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;When behavior differs by user state, don't assume it's a permissions/eligibility &lt;em&gt;setting&lt;/em&gt; — check for &lt;strong&gt;custom filters&lt;/strong&gt; hooked into the same pipeline, especially in code you didn't write yourself.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;grep&lt;/code&gt; is 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.&lt;/li&gt;
&lt;li&gt;When static analysis stalls, ask the running application directly. &lt;code&gt;wp eval&lt;/code&gt; and inspecting &lt;code&gt;$wp_filter&lt;/code&gt; at runtime found in minutes what days of file searching couldn't.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>webdev</category>
      <category>debugging</category>
    </item>
    <item>
      <title>The Button That Sent People to a Blank Form</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Wed, 22 Jul 2026 11:03:55 +0000</pubDate>
      <link>https://dev.to/highcenburg/the-button-that-sent-people-to-a-blank-form-42hj</link>
      <guid>https://dev.to/highcenburg/the-button-that-sent-people-to-a-blank-form-42hj</guid>
      <description>&lt;p&gt;Every service page on my portfolio ends with the same call to action: a big &lt;strong&gt;Start Project&lt;/strong&gt; button. Click it on the Automation page, and you land on the contact form ready to talk about automation. That was the idea, anyway.&lt;/p&gt;

&lt;p&gt;What actually happened: you'd click &lt;em&gt;Start Project&lt;/em&gt; on &lt;code&gt;/services/automation-solutions&lt;/code&gt;, get redirected to &lt;code&gt;/contact&lt;/code&gt;, and the "What do you need?" picker would be sitting there completely unselected. The one piece of context the user had just handed me — &lt;em&gt;I want automation&lt;/em&gt; — was thrown away the moment they navigated. They'd have to re-tell me what they came to tell me.&lt;/p&gt;

&lt;p&gt;This is a small bug. It's also exactly the kind of friction that quietly costs conversions. Here's the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;There are actually two contact forms in this codebase, which is where the confusion started.&lt;/p&gt;

&lt;p&gt;The service pages (&lt;code&gt;ServicePage.tsx&lt;/code&gt;) each render their own inline contact form near the bottom, and that one &lt;em&gt;already&lt;/em&gt; pre-selected the right service — it derives the default from the URL slug:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SLUG_TO_SERVICE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;software-development&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;software&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shopify-development&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shopify&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;automation-solutions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;automation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;defaultService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SLUG_TO_SERVICE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;ServiceValue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the &lt;strong&gt;Start Project&lt;/strong&gt; button in the hero didn't scroll to that inline form. It navigated to the standalone contact page, a completely separate component (&lt;code&gt;ContactPage.tsx&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;navigate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/contact&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  Start Project &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ArrowRight&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;ContactPage&lt;/code&gt; always initialized its form with an empty service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setForm&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;FormData&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;timeline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;website&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;honeypot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the button crossed a boundary — from a page that knew what the user wanted to a page that had no idea — and nothing carried the context across.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Carry the Intent in the URL
&lt;/h2&gt;

&lt;p&gt;The cleanest way to pass a small piece of state across a navigation is the URL itself. A query param is shareable, bookmarkable, survives a refresh, and doesn't need any global store. So the button now appends the service it already knows about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;
  &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;navigate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;defaultService&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`/contact?service=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;defaultService&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/contact&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  Start Project &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ArrowRight&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the guard: if the slug maps to nothing (&lt;code&gt;defaultService&lt;/code&gt; is &lt;code&gt;''&lt;/code&gt;), it falls back to a plain &lt;code&gt;/contact&lt;/code&gt; instead of producing an ugly &lt;code&gt;/contact?service=&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On the receiving end, &lt;code&gt;ContactPage&lt;/code&gt; reads the param on mount and validates it before trusting it. That validation matters — a query param is user-editable, and I don't want &lt;code&gt;?service=drop-table&lt;/code&gt; (or just a typo) to put the form into a weird state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useSearchParams&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-router-dom&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;VALID_SERVICES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;SERVICES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;normalizeService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;ServiceValue&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;VALID_SERVICES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;ServiceValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I use a &lt;strong&gt;lazy initializer&lt;/strong&gt; for the form state so the param is applied exactly once, when the component first mounts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useSearchParams&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setForm&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;FormData&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;normalizeService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;service&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="na"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;timeline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;website&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;honeypot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole change — two files, about eleven lines. The service picker now highlights the right option the moment the page loads, and because the budget selector only appears &lt;em&gt;after&lt;/em&gt; a service is chosen, arriving from a service page also reveals the correct price ranges immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Subtlety About When It Runs
&lt;/h2&gt;

&lt;p&gt;The lazy initializer only runs once, on mount. That's deliberate, and it's worth understanding why it's correct here.&lt;/p&gt;

&lt;p&gt;When a user clicks &lt;em&gt;Start Project&lt;/em&gt;, they navigate from the &lt;code&gt;/services/...&lt;/code&gt; route to the &lt;code&gt;/contact&lt;/code&gt; route. React Router unmounts one page component and mounts the other — a fresh mount, so the initializer runs and reads the param. &lt;/p&gt;

&lt;p&gt;But if you were &lt;em&gt;already&lt;/em&gt; on &lt;code&gt;/contact&lt;/code&gt; and only the query string changed, React Router would keep the same component mounted and the initializer would &lt;strong&gt;not&lt;/strong&gt; re-run. For a second I wondered if I needed a &lt;code&gt;useEffect&lt;/code&gt; to sync on param changes. I decided against it: there's no UI path where the query param changes while you're sitting on the contact page. Adding an effect to handle a situation that can't happen is just a new bug waiting to be misunderstood later. If that ever changes, the effect is easy to add.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing It for Real
&lt;/h2&gt;

&lt;p&gt;I didn't want to eyeball this one. A pre-select bug is exactly the kind of thing that "looks fine" and silently regresses, so I drove a real browser against the dev server and checked the actual DOM state instead of trusting a screenshot.&lt;/p&gt;

&lt;p&gt;The selected service button carries a &lt;code&gt;-translate-y-1&lt;/code&gt; class (the little neubrutalist "lift"), so I could assert on it directly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;URL after action&lt;/th&gt;
&lt;th&gt;Selected option&lt;/th&gt;
&lt;th&gt;Budget section&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Click &lt;em&gt;Start Project&lt;/em&gt; on Automation&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/contact?service=automation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Automation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Visible, automation ranges&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Land on &lt;code&gt;/contact&lt;/code&gt; directly&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/contact&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(none)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;Hidden&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tampered &lt;code&gt;?service=bogus&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/contact?service=bogus&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(none)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;Hidden&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The middle and bottom rows are the ones I actually cared about — they prove the feature doesn't leak into the no-param case and that &lt;code&gt;normalizeService&lt;/code&gt; throws away garbage instead of choking on it.&lt;/p&gt;

&lt;p&gt;One gotcha while testing: my first attempt navigated between cases with &lt;code&gt;history.pushState&lt;/code&gt; inside the running app, and every case came back showing "Automation" still selected. That wasn't a bug in the fix — it was the &lt;em&gt;same&lt;/em&gt; behavior I described above. &lt;code&gt;pushState&lt;/code&gt; doesn't remount &lt;code&gt;ContactPage&lt;/code&gt;, so the initializer never re-ran and the old state persisted. Switching to full page loads for each case gave honest results. A nice reminder that your test harness has to respect the component lifecycle you're actually shipping.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;p&gt;The bug wasn't a broken function. Everything worked in isolation — the button navigated, the form rendered, the picker picked. The bug lived in the &lt;em&gt;gap between two components&lt;/em&gt;, where a piece of user intent quietly fell on the floor.&lt;/p&gt;

&lt;p&gt;The URL is a great place to catch things falling through those gaps. It's the one piece of state both sides of a navigation can always agree on. Put the intent in the query string, validate it on the way in, and the two pages don't need to know anything about each other.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>typescript</category>
      <category>react</category>
      <category>testing</category>
    </item>
    <item>
      <title>I Added Sentry to a WooCommerce Membership Site. It Caught a Real Bug Within the Hour.</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Mon, 20 Jul 2026 13:16:19 +0000</pubDate>
      <link>https://dev.to/highcenburg/i-added-sentry-to-a-woocommerce-membership-site-it-caught-a-real-bug-within-the-hour-5d4e</link>
      <guid>https://dev.to/highcenburg/i-added-sentry-to-a-woocommerce-membership-site-it-caught-a-real-bug-within-the-hour-5d4e</guid>
      <description>&lt;p&gt;I'm the lead developer on a WooCommerce/Divi/LearnDash membership platform. Like most mature WordPress sites, it runs a lot of plugins — page builder, LMS, membership gating, marketing automation, forms, SMTP — and when something breaks, the failure usually surfaces as a vague member complaint days later, not as an actionable error with a stack trace.&lt;/p&gt;

&lt;p&gt;I finally fixed that by wiring up &lt;a href="https://sentry.io" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt; with the &lt;a href="https://wordpress.org/plugins/wp-sentry-integration/" rel="noopener noreferrer"&gt;WordPress Sentry plugin&lt;/a&gt; (&lt;code&gt;wp-sentry-integration&lt;/code&gt;). Setup took about twenty minutes. The first real bug showed up in the feed within the hour — an error loop that had been silently firing on every hit to a REST endpoint, and that nobody would ever have reported, because it never broke anything a user could see.&lt;/p&gt;

&lt;p&gt;This post covers the setup, the gotchas, and the first catch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the wp-sentry plugin and not the raw SDK snippets
&lt;/h2&gt;

&lt;p&gt;When you create a Sentry project, the onboarding page hands you a &lt;code&gt;\Sentry\init()&lt;/code&gt; snippet for PHP and a loader &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag for the browser. You don't want either of them on a WordPress site.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;wp-sentry-integration&lt;/code&gt; plugin bundles both official SDKs and does the integration work you'd otherwise hand-roll:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Initializes the PHP SDK early enough in the WordPress bootstrap to catch fatals from other plugins&lt;/li&gt;
&lt;li&gt;Hooks PHP errors, exceptions, and fatal errors&lt;/li&gt;
&lt;li&gt;Enqueues the browser SDK on the frontend and wires up tracing and Session Replay&lt;/li&gt;
&lt;li&gt;Attaches WordPress context to every event: the logged-in user, environment, release, and plugin/theme metadata&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The part that confused me at first: the plugin's settings page has checkboxes, and none of them are clickable. That's by design. &lt;strong&gt;The plugin has zero UI configuration.&lt;/strong&gt; Everything is driven by constants in &lt;code&gt;wp-config.php&lt;/code&gt;, and the settings page is a read-only dashboard that reports which constants it detected. Checkbox ticked means the constant exists and is valid. You can click them all day and nothing happens.&lt;/p&gt;

&lt;p&gt;I've come around to this being the right call. Your error-tracking config lives in code, survives plugin updates, can't be broken from wp-admin, and a compromised admin account can't quietly redirect your error stream to someone else's DSN. Same philosophy as &lt;code&gt;DISABLE_WP_CRON&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Two Sentry projects, not one
&lt;/h3&gt;

&lt;p&gt;Create separate projects for PHP and browser JavaScript. On a plugin-heavy WooCommerce site, browser noise — extension errors, ancient browsers, third-party scripts — will drown out real server-side errors if they share a project. Splitting them keeps triage sane and lets you set different alert rules and quotas per side.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Constants in wp-config.php
&lt;/h3&gt;

&lt;p&gt;Everything goes above the &lt;code&gt;/* That's all, stop editing! */&lt;/code&gt; line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// --- Sentry ---&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WP_SENTRY_PHP_DSN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'https://xxxx@oXXXX.ingest.us.sentry.io/PHP_PROJECT_ID'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WP_SENTRY_BROWSER_DSN'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'https://xxxx@oXXXX.ingest.us.sentry.io/JS_PROJECT_ID'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WP_SENTRY_ENV'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'production'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Attach the logged-in WP user (id/username/email) to events&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WP_SENTRY_SEND_DEFAULT_PII'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Keep tracing cheap — it eats quota fast&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WP_SENTRY_TRACES_SAMPLE_RATE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WP_SENTRY_BROWSER_TRACES_SAMPLE_RATE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Session Replay: no ambient recording, full replay on error&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WP_SENTRY_BROWSER_REPLAYS_SESSION_SAMPLE_RATE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WP_SENTRY_BROWSER_REPLAYS_ON_ERROR_SAMPLE_RATE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One trap: the browser value must be the &lt;strong&gt;DSN&lt;/strong&gt; from Settings → Client Keys, not the CDN loader script URL that Sentry's JS onboarding page shows you. They look superficially similar. The DSN has the &lt;code&gt;https://key@org.ingest...sentry.io/project_id&lt;/code&gt; shape.&lt;/p&gt;

&lt;p&gt;A few decisions worth explaining:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SEND_DEFAULT_PII&lt;/code&gt; on a membership site&lt;/strong&gt; is genuinely useful — you can tie a checkout error directly to the member who reported it instead of playing email tag. The tradeoff is that member emails now live in Sentry, so lock down team access and account for it in your privacy policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replay sample rates of 0 / 1.0&lt;/strong&gt; means no routine session recording, but when a JS error fires you get the full replay of what the user did leading up to it. On checkout flows this is gold. Sentry masks all text and inputs by default; verify that before trusting it near payment forms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skip profiling.&lt;/strong&gt; It requires the &lt;code&gt;excimer&lt;/code&gt; PHP extension, which most managed WordPress hosts don't ship. Tracing plus replay covers what you actually need.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Verify
&lt;/h3&gt;

&lt;p&gt;Reload the plugin's tools page — the checkboxes should now be ticked and the test buttons active. Fire the PHP test event and the browser test error, and confirm each lands in the correct project. The browser test on my setup also captured a session replay, which confirmed the whole replay pipeline in one shot.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Turn on inbound filters immediately
&lt;/h3&gt;

&lt;p&gt;In the JS project: Settings → Inbound Filters → enable the browser-extension, legacy-browser, and web-crawler filters. Do this on day one, not after your quota is gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first catch
&lt;/h2&gt;

&lt;p&gt;Within the hour, a real issue appeared in the PHP project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Exception: Scheduled action for bwfcrm_broadcast_run_queue will not be
executed as no callbacks are registered.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Firing from &lt;code&gt;GET /wp-json/woofunnels/v1/worker&lt;/code&gt; — FunnelKit's background worker endpoint.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reading the trace
&lt;/h3&gt;

&lt;p&gt;The exception came from Action Scheduler's safety check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nf"&gt;has_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$hook&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Scheduled action ... will not be executed
        as no callbacks are registered.'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Action Scheduler refuses to run a scheduled job that has no listeners — it treats a dangling hook as a broken state and throws.&lt;/p&gt;

&lt;p&gt;The hook, &lt;code&gt;bwfcrm_broadcast_run_queue&lt;/code&gt;, belongs to FunnelKit's CRM/broadcast module. The site doesn't use email broadcasts (I confirmed with the client — a one-line message, thirty seconds), so nothing registers that hook anymore. But the &lt;em&gt;recurring action&lt;/em&gt; for it still lived in FunnelKit's custom action store, scheduled to run every 60 seconds.&lt;/p&gt;

&lt;p&gt;Sentry's SQL breadcrumbs made the loop visible: the worker fetches the action, the execution throws, and the fetch path re-schedules the next occurrence. An orphaned job, erroring and rebooking itself forever. It had presumably been doing this since whenever the CRM module was deactivated — invisible, because the exception is handled and nothing user-facing ever broke.&lt;/p&gt;

&lt;p&gt;This is exactly the class of bug error monitoring exists for. No member would ever report it. No admin screen showed it. It was just background waste: a query cycle and a thrown exception on every worker run, on a site that already had a history of Action Scheduler bloat.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix: delete the work, keep a guard
&lt;/h3&gt;

&lt;p&gt;Two parts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Delete the orphaned recurring action.&lt;/strong&gt; The table and hook name come straight from the event's own breadcrumbs, so the query is exact — but look before you delete:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp db query &lt;span class="s2"&gt;"SELECT id, hook, status, recurring_interval, group_slug
  FROM wp_bwf_actions
  WHERE hook = 'bwfcrm_broadcast_run_queue';"&lt;/span&gt;

wp db query &lt;span class="s2"&gt;"DELETE FROM wp_bwf_actions
  WHERE hook = 'bwfcrm_broadcast_run_queue';"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Adjust the table prefix for your install.) This isn't as scary as deleting from an unknown table sounds: the plugin's own queue runner deletes rows from this table constantly as part of normal processing — the last breadcrumb in the Sentry event was literally a &lt;code&gt;DELETE&lt;/code&gt; on the same row. Worst case, re-enabling the CRM module re-registers the hook and re-seeds the schedule from scratch. There's no unrecoverable state in a row that says "run this hook every 60 seconds."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Register a no-op guard.&lt;/strong&gt; Some plugins have a habit of re-seeding their recurring actions on init or after updates. If that happens here, I don't want the error loop back — so I registered a listener that does nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Guard: orphaned FunnelKit CRM broadcast hook.&lt;/span&gt;
&lt;span class="c1"&gt;// The bwfcrm_broadcast_run_queue recurring action has no registered&lt;/span&gt;
&lt;span class="c1"&gt;// callback (CRM/broadcasts unused — confirmed with the client).&lt;/span&gt;
&lt;span class="c1"&gt;// This no-op prevents Action Scheduler from throwing if the action&lt;/span&gt;
&lt;span class="c1"&gt;// gets re-seeded. Safe to remove if FunnelKit CRM is ever activated.&lt;/span&gt;
&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'bwfcrm_broadcast_run_queue'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'__return_null'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;__return_null&lt;/code&gt; is a WordPress core utility whose entire body is &lt;code&gt;return null;&lt;/code&gt;. Registering it means &lt;code&gt;has_action()&lt;/code&gt; returns true, the safety check passes, and a re-seeded action "runs" harmlessly instead of restarting the error stream.&lt;/p&gt;

&lt;p&gt;I put this in Code Snippets rather than the theme's &lt;code&gt;functions.php&lt;/code&gt; — it's an infrastructure fix, not presentation code, and it should survive theme switches, stay individually toggleable, and be findable by name by the next developer (or by me in six months). The comment block matters as much as the code: a bare &lt;code&gt;add_action( 'something', '__return_null' )&lt;/code&gt; with no explanation is a trap for whoever finds it later.&lt;/p&gt;

&lt;p&gt;Then: resolve the issue in Sentry. If the action ever re-seeds &lt;em&gt;and&lt;/em&gt; the guard is somehow gone, Sentry reopens the issue and I know immediately — that's regression detection for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;On WordPress, configure Sentry through the wp-sentry plugin's constants, not the onboarding snippets.&lt;/strong&gt; The plugin is the engine; &lt;code&gt;wp-config.php&lt;/code&gt; is the ignition key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split PHP and browser into separate projects&lt;/strong&gt; and enable inbound filters on the JS side before the noise starts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handled exceptions are still signal.&lt;/strong&gt; The bug I found threw a &lt;em&gt;caught&lt;/em&gt; exception — nothing crashed, no user saw anything. Without monitoring it would have run forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sentry breadcrumbs can hand you the fix.&lt;/strong&gt; The SQL breadcrumbs contained the exact table, the exact row, and proof that deleting the row was an operation the plugin itself performs routinely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix orphaned scheduled actions in two layers:&lt;/strong&gt; delete the orphan (stop the waste), and register a no-op guard (stay fixed if the plugin re-seeds it). Then resolve the issue in Sentry and let it be your regression alarm.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total time from "install the plugin" to "real production bug found, diagnosed from the stack trace and breadcrumbs, fixed with a two-layer patch": about an hour. That's the fastest an observability tool has ever paid for itself on one of my projects.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>webdev</category>
      <category>sentry</category>
    </item>
    <item>
      <title>Closing the Loop: Emailing Myself When My Resume Gets Downloaded</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Fri, 17 Jul 2026 12:06:56 +0000</pubDate>
      <link>https://dev.to/highcenburg/closing-the-loop-emailing-myself-when-my-resume-gets-downloaded-h8j</link>
      <guid>https://dev.to/highcenburg/closing-the-loop-emailing-myself-when-my-resume-gets-downloaded-h8j</guid>
      <description>&lt;p&gt;I already had a &lt;a href="https://www.vicentereyes.org/blog/building-a-resume-download-gate-email-collection-signed-tokens-and-an-s3-lesson-550fb050" rel="noopener noreferrer"&gt;resume download gate&lt;/a&gt; — email required, signed link, 15-minute expiry, presigned S3 URL good for two minutes. What it didn't have was a way for &lt;em&gt;me&lt;/em&gt; to know it happened. Someone could download my resume and I'd never find out unless I went digging through &lt;code&gt;ResumeDownloadRequest&lt;/code&gt; rows in the admin.&lt;/p&gt;

&lt;p&gt;This is the short follow-up: one notification email, sent to me, the moment someone actually opens the PDF.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where to Hook It
&lt;/h2&gt;

&lt;p&gt;The gate has two views:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ResumeRequestDownloadView.post()&lt;/code&gt; — fires when someone submits their email and gets sent a link&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ResumeDownloadView.get()&lt;/code&gt; — fires when someone clicks that link and the signed token is verified&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Submitting an email isn't the same as downloading. Plenty of people fill in a form and never check their inbox. I wanted the notification tied to a confirmed download, so it belongs in &lt;code&gt;ResumeDownloadView.get()&lt;/code&gt; — right after the token is verified and the resume is confirmed to exist, just before the redirect to the presigned S3 URL.&lt;/p&gt;




&lt;h2&gt;
  
  
  Finding the Right Recipient
&lt;/h2&gt;

&lt;p&gt;My first instinct was to reach for &lt;code&gt;settings.ADMINS&lt;/code&gt;, Django's built-in list for &lt;code&gt;mail_admins()&lt;/code&gt;. But &lt;code&gt;ADMINS&lt;/code&gt; in &lt;code&gt;config/settings/base.py&lt;/code&gt; looked like a placeholder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;ADMINS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Vicente G. Reyes&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;me@example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;me@example.com&lt;/code&gt; — that &lt;code&gt;x&lt;/code&gt; reads exactly like scaffolding nobody replaced. I almost went and fixed it to a real inbox before sending anything.&lt;/p&gt;

&lt;p&gt;Then I noticed something else: &lt;code&gt;.envs/.production/.django&lt;/code&gt; also had &lt;code&gt;DJANGO_SERVER_EMAIL=x&lt;/code&gt;. Two placeholder-looking values with the same address wasn't a coincidence — it turned out &lt;code&gt;me@example.com&lt;/code&gt; is a real catch-all alias on the domain, already wired to forward to an inbox that gets checked. Not a placeholder at all.&lt;/p&gt;

&lt;p&gt;That distinction mattered for a second reason: &lt;code&gt;SERVER_EMAIL&lt;/code&gt; and &lt;code&gt;ADMINS&lt;/code&gt; do different jobs in Django. &lt;code&gt;SERVER_EMAIL&lt;/code&gt; is the &lt;strong&gt;From&lt;/strong&gt; address Django uses when it sends admin error emails via &lt;code&gt;mail_admins()&lt;/code&gt;. &lt;code&gt;ADMINS&lt;/code&gt; is the &lt;strong&gt;recipient&lt;/strong&gt; list for those same emails. Confusing the two would have had me trying to "fix" a sender address as if it were a destination.&lt;/p&gt;

&lt;p&gt;Given &lt;code&gt;me@example.com&lt;/code&gt; was already correct and already sitting in &lt;code&gt;ADMINS&lt;/code&gt;, there was nothing to add. I just needed to read from the setting that already existed.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Change
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;project/core/views.py&lt;/code&gt; — inside &lt;code&gt;ResumeDownloadView&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ResumeDownloadView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;APIView&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;authentication_classes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;permission_classes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query_params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;errors&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;This field is required.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;
                &lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTP_400_BAD_REQUEST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;signer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;signing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TimestampSigner&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;contextlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;suppress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SignatureExpired&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BadSignature&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;signer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unsign_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_age&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;RESUME_TOKEN_MAX_AGE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;signer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unsign_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Download link expired. Please request a new one.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;errors&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="p"&gt;},&lt;/span&gt;
                    &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTP_410_GONE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;signing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BadSignature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invalid download token.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;errors&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="p"&gt;},&lt;/span&gt;
                    &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTP_400_BAD_REQUEST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;resume&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Resume&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pk&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;resume&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Resume not found.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;errors&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTP_404_NOT_FOUND&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_notify_owner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HttpResponseRedirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_notify_owner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;downloader_email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;recipients&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;admin_email&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;admin_email&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ADMINS&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;recipients&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Someone just downloaded your resume.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Email: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;downloader_email&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Time: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%Y-%m-%d %H&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;M&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;S&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;Z&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;contextlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;suppress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="nf"&gt;send_mail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Resume downloaded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;from_email&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DEFAULT_FROM_EMAIL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;recipient_list&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;recipients&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;fail_silently&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same pattern as the existing &lt;code&gt;_send_download_email&lt;/code&gt; right above it in the file: plain &lt;code&gt;send_mail&lt;/code&gt;, wrapped in &lt;code&gt;contextlib.suppress(Exception)&lt;/code&gt;, &lt;code&gt;fail_silently=True&lt;/code&gt;. If Mailgun hiccups, the downloader still gets their redirect — a failed notification email should never block the actual download.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;recipients&lt;/code&gt; iterates all of &lt;code&gt;ADMINS&lt;/code&gt; rather than hardcoding &lt;code&gt;settings.ADMINS[0][1]&lt;/code&gt;, so adding a second admin later needs zero changes here.&lt;/p&gt;

&lt;p&gt;One import needed adding — &lt;code&gt;django.utils.timezone&lt;/code&gt;, for the timestamp in the notification body.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tests
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;project/core/tests.py&lt;/code&gt; had zero coverage for the resume download flow before this — the gate shipped without tests originally. Added a fixture for a &lt;code&gt;Resume&lt;/code&gt; with a real uploaded file, a fixture for a signed token, and a test class covering the redirect, the notification, and the existing error paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.core&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;signing&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.core.files.uploadedfile&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SimpleUploadedFile&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.urls&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;rest_framework.test&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;APIClient&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;project.blogs.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BlogPost&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;project.projects.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Project&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;project.services.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Service&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FAQ&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Resume&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Skill&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Stat&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Testimonial&lt;/span&gt;


&lt;span class="c1"&gt;# ---------------------------------------------------------------------------
# Resume download
# ---------------------------------------------------------------------------
&lt;/span&gt;

&lt;span class="nd"&gt;@pytest.fixture&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Resume&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;SimpleUploadedFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resume.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%PDF-1.4 fake&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nd"&gt;@pytest.fixture&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resume_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;signer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;signing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TimestampSigner&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;signer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign_object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;downloader@example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;


&lt;span class="nd"&gt;@pytest.mark.django_db&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TestResumeDownloadView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_valid_token_redirects_to_pdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resume_token&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api:resume-download&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;?token=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;resume_token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;302&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_valid_token_notifies_owner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resume_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mailoutbox&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api:resume-download&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;?token=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;resume_token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mailoutbox&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;mailoutbox&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Resume downloaded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;mailoutbox&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;me@example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;downloader@example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;mailoutbox&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_missing_token_returns_400&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api:resume-download&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_invalid_token_returns_400&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api:resume-download&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;?token=garbage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_unknown_resume_pk_returns_404&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;signer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;signing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TimestampSigner&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;signer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign_object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;999&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;downloader@example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api:resume-download&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;?token=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mailoutbox&lt;/code&gt; is a &lt;code&gt;pytest-django&lt;/code&gt; fixture — it's Django's test &lt;code&gt;EmailBackend&lt;/code&gt; capturing every send in a list, no mocking required. Asserting on &lt;code&gt;mailoutbox[0].to&lt;/code&gt; doubles as a regression check: if &lt;code&gt;ADMINS&lt;/code&gt; ever gets edited back to a placeholder, this test fails immediately instead of silently emailing nobody.&lt;/p&gt;

&lt;p&gt;Ran inside the project's Docker dev container, matching how every other management command in this repo gets run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker compose -f docker-compose.local.yml run --rm django pytest project/core/tests.py -k Resume -v
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;project/core/tests.py::TestResumeDownloadView::test_valid_token_redirects_to_pdf PASSED
project/core/tests.py::TestResumeDownloadView::test_valid_token_notifies_owner PASSED
project/core/tests.py::TestResumeDownloadView::test_missing_token_returns_400 PASSED
project/core/tests.py::TestResumeDownloadView::test_invalid_token_returns_400 PASSED
project/core/tests.py::TestResumeDownloadView::test_unknown_resume_pk_returns_404 PASSED

5 passed in 5.31s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Batch the notification if downloads spike.&lt;/strong&gt; Right now every single download sends a separate email. Fine at current traffic; if this ever got linked from somewhere with real volume, I'd want a short debounce window rather than an inbox flood.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Move the send off the request path.&lt;/strong&gt; Same tradeoff as the original gate's &lt;code&gt;send_mail&lt;/code&gt; call — it's synchronous, so the redirect waits on the SMTP round trip to Mailgun. No Celery in this project yet, so for now it's a few hundred milliseconds tacked onto every download. Worth revisiting if Celery ever gets added for other reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Double-check &lt;code&gt;ADMINS&lt;/code&gt; before trusting it elsewhere.&lt;/strong&gt; This whole feature nearly took a wrong turn because a real address happened to look exactly like unfinished scaffolding. Worth a settings comment noting &lt;code&gt;me@example.com&lt;/code&gt; is a live catch-all, not a TODO.&lt;/p&gt;

</description>
      <category>django</category>
      <category>python</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
    <item>
      <title>TIL: identify. Check it out! man identify</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Wed, 15 Jul 2026 12:40:56 +0000</pubDate>
      <link>https://dev.to/highcenburg/til-identify-check-it-out-man-identify-5cn0</link>
      <guid>https://dev.to/highcenburg/til-identify-check-it-out-man-identify-5cn0</guid>
      <description></description>
      <category>cli</category>
      <category>learning</category>
      <category>linux</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Automating Django `dumpdata` Backups to S3 (One Command, Zero SSH)</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Thu, 09 Jul 2026 12:03:27 +0000</pubDate>
      <link>https://dev.to/highcenburg/automating-django-dumpdata-backups-to-s3-one-command-zero-ssh-1303</link>
      <guid>https://dev.to/highcenburg/automating-django-dumpdata-backups-to-s3-one-command-zero-ssh-1303</guid>
      <description>&lt;p&gt;If you're running a cookiecutter-django project on a droplet and still &lt;code&gt;ssh&lt;/code&gt;-ing in every time you need a data snapshot, here's how I collapsed that into a single local command: dump → pull → upload to S3, no manual steps in between.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;My portfolio backend runs cookiecutter-django, containerized via Docker Compose, on a DigitalOcean droplet. Every time I wanted a fresh data snapshot — before a schema change, before a risky migration, or just as a periodic backup — the process looked like:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;SSH into the droplet&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cd&lt;/code&gt; into the project directory&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;dumpdata&lt;/code&gt; inside the Django container&lt;/li&gt;
&lt;li&gt;Copy the resulting file out of the container&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scp&lt;/code&gt; it down to my local machine&lt;/li&gt;
&lt;li&gt;Manually upload it to S3 for offsite storage&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Six steps, every time. That's the kind of manual toil that either doesn't happen often enough, or happens with copy-paste mistakes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Get &lt;code&gt;dumpdata&lt;/code&gt; Right
&lt;/h2&gt;

&lt;p&gt;The naive &lt;code&gt;manage.py dumpdata &amp;gt; data.json&lt;/code&gt; works, but it dumps everything — including tables that don't need to be in a portable fixture and will actively cause problems if you ever &lt;code&gt;loaddata&lt;/code&gt; this into a different environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python manage.py dumpdata &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--natural-foreign&lt;/span&gt; &lt;span class="nt"&gt;--natural-primary&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exclude&lt;/span&gt; auth.permission &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exclude&lt;/span&gt; contenttypes &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exclude&lt;/span&gt; admin.logentry &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exclude&lt;/span&gt; sessions.session &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--indent&lt;/span&gt; 2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; data.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why these flags matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--natural-foreign --natural-primary&lt;/code&gt;&lt;/strong&gt; — replaces hardcoded primary keys with natural lookups where possible. Without this, reloading the fixture into an environment with different PK sequences (a fresh DB, a staging environment) can silently corrupt relationships.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Excluding &lt;code&gt;contenttypes&lt;/code&gt;, &lt;code&gt;auth.permission&lt;/code&gt;, &lt;code&gt;admin.logentry&lt;/code&gt;, &lt;code&gt;sessions.session&lt;/code&gt;&lt;/strong&gt; — these are either auto-regenerated by Django on any fresh install or pure operational noise. Including them bloats the dump and can cause &lt;code&gt;loaddata&lt;/code&gt; conflicts if content types are ever re-registered in a different order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--indent 2&lt;/code&gt;&lt;/strong&gt; — makes the output diffable in git if you're versioning snapshots, instead of one giant unreadable line.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 2: Run It Inside Docker Correctly
&lt;/h2&gt;

&lt;p&gt;If your project is containerized, the command has to run inside the &lt;code&gt;django&lt;/code&gt; service, not on the host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.production.yml &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; django python manage.py dumpdata &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--natural-foreign&lt;/span&gt; &lt;span class="nt"&gt;--natural-primary&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exclude&lt;/span&gt; auth.permission &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exclude&lt;/span&gt; contenttypes &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exclude&lt;/span&gt; admin.logentry &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exclude&lt;/span&gt; sessions.session &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--indent&lt;/span&gt; 2 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; data.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that will bite you if skipped:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;-T&lt;/code&gt; flag disables TTY allocation.&lt;/strong&gt; Without it, &lt;code&gt;exec&lt;/code&gt; allocates a pseudo-terminal by default. Run this over a scripted or piped SSH session and you risk carriage returns (&lt;code&gt;\r\n&lt;/code&gt;) getting injected into your output, silently corrupting the JSON. Always pass &lt;code&gt;-T&lt;/code&gt; for non-interactive dumps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Redirect happens on the right side of the pipe.&lt;/strong&gt; Since &lt;code&gt;docker compose exec&lt;/code&gt; streams its own stdout, running this directly on the host (or via &lt;code&gt;ssh host "command"&lt;/code&gt;) means the &lt;code&gt;&amp;gt;&lt;/code&gt; redirect captures cleanly outsid# Automating Django &lt;code&gt;dumpdata&lt;/code&gt; Backups to S3 (One Command, Zero SSH)&lt;/p&gt;

&lt;p&gt;If you're running a cookiecutter-django project on a droplet and still &lt;code&gt;ssh&lt;/code&gt;-ing in every time you need a data snapshot, here's how I collapsed that into a single local command: dump → pull → upload to S3, no manual steps in between.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;My portfolio backend runs cookiecutter-django, containerized via Docker Compose, on a DigitalOcean droplet. Every time I wanted a fresh data snapshot — before a schema change, before a risky migration, or just as a periodic backup — the process looked like:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;SSH into the droplet&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cd&lt;/code&gt; into the project directory&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;dumpdata&lt;/code&gt; inside the Django container&lt;/li&gt;
&lt;li&gt;Copy the resulting file out of the container&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scp&lt;/code&gt; it down to my local machine&lt;/li&gt;
&lt;li&gt;Manually upload it to S3 for offsite storage&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Six steps, every time. That's the kind of manual toil that either doesn't happen often enough, or happens with copy-paste mistakes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Get &lt;code&gt;dumpdata&lt;/code&gt; Right
&lt;/h2&gt;

&lt;p&gt;The naive &lt;code&gt;manage.py dumpdata &amp;gt; data.json&lt;/code&gt; works, but it dumps everything — including tables that don't need to be in a portable fixture and will actively cause problems if you ever &lt;code&gt;loaddata&lt;/code&gt; this into a different environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python manage.py dumpdata &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--natural-foreign&lt;/span&gt; &lt;span class="nt"&gt;--natural-primary&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exclude&lt;/span&gt; auth.permission &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exclude&lt;/span&gt; contenttypes &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exclude&lt;/span&gt; admin.logentry &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exclude&lt;/span&gt; sessions.session &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--indent&lt;/span&gt; 2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; data.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why these flags matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--natural-foreign --natural-primary&lt;/code&gt;&lt;/strong&gt; — replaces hardcoded primary keys with natural lookups where possible. Without this, reloading the fixture into an environment with different PK sequences (a fresh DB, a staging environment) can silently corrupt relationships.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Excluding &lt;code&gt;contenttypes&lt;/code&gt;, &lt;code&gt;auth.permission&lt;/code&gt;, &lt;code&gt;admin.logentry&lt;/code&gt;, &lt;code&gt;sessions.session&lt;/code&gt;&lt;/strong&gt; — these are either auto-regenerated by Django on any fresh install or pure operational noise. Including them bloats the dump and can cause &lt;code&gt;loaddata&lt;/code&gt; conflicts if content types are ever re-registered in a different order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--indent 2&lt;/code&gt;&lt;/strong&gt; — makes the output diffable in git if you're versioning snapshots, instead of one giant unreadable line.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 2: Run It Inside Docker Correctly
&lt;/h2&gt;

&lt;p&gt;If your project is containerized, the command has to run inside the &lt;code&gt;django&lt;/code&gt; service, not on the host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.production.yml &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; django python manage.py dumpdata &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--natural-foreign&lt;/span&gt; &lt;span class="nt"&gt;--natural-primary&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exclude&lt;/span&gt; auth.permission &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exclude&lt;/span&gt; contenttypes &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exclude&lt;/span&gt; admin.logentry &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exclude&lt;/span&gt; sessions.session &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--indent&lt;/span&gt; 2 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; data.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that will bite you if skipped:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;-T&lt;/code&gt; flag disables TTY allocation.&lt;/strong&gt; Without it, &lt;code&gt;exec&lt;/code&gt; allocates a pseudo-terminal by default. Run this over a scripted or piped SSH session and you risk carriage returns (&lt;code&gt;\r\n&lt;/code&gt;) getting injected into your output, silently corrupting the JSON. Always pass &lt;code&gt;-T&lt;/code&gt; for non-interactive dumps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Redirect happens on the right side of the pipe.&lt;/strong&gt; Since &lt;code&gt;docker compose exec&lt;/code&gt; streams its own stdout, running this directly on the host (or via &lt;code&gt;ssh host "command"&lt;/code&gt;) means the &lt;code&gt;&amp;gt;&lt;/code&gt; redirect captures cleanly outside the container — no separate &lt;code&gt;docker compose cp&lt;/code&gt; step needed, unlike if you'd used &lt;code&gt;-o data.json&lt;/code&gt; inside the container itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Collapse the Whole Thing Into One Alias
&lt;/h2&gt;

&lt;p&gt;Here's the full pipeline — SSH in, dump, and stream the result straight to a local file — as a single shell alias:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;dumpdata&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'ssh username@your-droplet-ip "cd ~/root/project &amp;amp;&amp;amp; docker compose -f docker-compose.production.yml exec -T django python manage.py dumpdata --natural-foreign --natural-primary --exclude auth.permission --exclude contenttypes --exclude admin.logentry --exclude sessions.session --indent 2" &amp;gt; ~/Downloads/data.json'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;dumpdata&lt;/code&gt; from your local machine, and the entire remote pipeline executes and lands a clean &lt;code&gt;data.json&lt;/code&gt; in your Downloads folder. No manual SSH session, no intermediate files left behind on the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Ship It to S3
&lt;/h2&gt;

&lt;p&gt;With AWS CLI configured (&lt;code&gt;aws configure&lt;/code&gt;, or a named profile if you juggle multiple client accounts):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws s3 &lt;span class="nb"&gt;cp&lt;/span&gt; ~/Downloads/data.json s3://your-bucket/backups/data-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d-%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The timestamped key matters — without it, every backup silently overwrites the last one, and you lose the ability to roll back to an earlier snapshot.&lt;/p&gt;

&lt;p&gt;If credentials aren't configured yet, &lt;code&gt;aws s3 cp&lt;/code&gt; fails with &lt;code&gt;Unable to locate credentials&lt;/code&gt;. Fix it with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws configure &lt;span class="nt"&gt;--profile&lt;/span&gt; your-profile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using a named profile instead of the default is worth the extra keystroke if you handle backups for multiple clients or projects — it keeps credential scope explicit instead of relying on whatever happens to be the account's default.&lt;/p&gt;

&lt;h2&gt;
  
  
  The One-Liner, Fully Chained
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;dumpdata&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'ssh username@your-droplet-ip "cd ~/root/project &amp;amp;&amp;amp; docker compose -f docker-compose.production.yml exec -T django python manage.py dumpdata --natural-foreign --natural-primary --exclude auth.permission --exclude contenttypes --exclude admin.logentry --exclude sessions.session --indent 2" &amp;gt; ~/Downloads/data.json &amp;amp;&amp;amp; aws s3 cp ~/Downloads/data.json "s3://your-bucket/backups/data-$(date +%Y%m%d-%H%M%S).json" --profile your-profile'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One command. Dumps remotely, pulls locally, uploads to S3 with a timestamped key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I'd Take It Next
&lt;/h2&gt;

&lt;p&gt;An alias is fine for on-demand backups, but it has no failure handling — if the SSH connection drops mid-stream, or the dump comes back empty, you'll happily upload a corrupted or zero-byte file to S3 without noticing. The next iteration I'm planning is a small bash script with &lt;code&gt;set -euo pipefail&lt;/code&gt;, a file-size sanity check before the S3 upload, and eventually a cron job on the droplet itself so backups happen on a schedule without me remembering to run anything at all.&lt;/p&gt;




&lt;p&gt;*Originally documented as part of ongoing DevOps work on the ICVN Tech Studio portfolio infrastructure (cookiecutter-django on DigitalOcean, Cloudflare-fronted).*e the container — no separate &lt;code&gt;docker compose cp&lt;/code&gt; step needed, unlike if you'd used &lt;code&gt;-o data.json&lt;/code&gt; inside the container itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Collapse the Whole Thing Into One Alias
&lt;/h2&gt;

&lt;p&gt;Here's the full pipeline — SSH in, dump, and stream the result straight to a local file — as a single shell alias:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;dumpdata&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'ssh username@your-droplet-ip "cd ~/root/project &amp;amp;&amp;amp; docker compose -f docker-compose.production.yml exec -T django python manage.py dumpdata --natural-foreign --natural-primary --exclude auth.permission --exclude contenttypes --exclude admin.logentry --exclude sessions.session --indent 2" &amp;gt; ~/Downloads/data.json'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;dumpdata&lt;/code&gt; from your local machine, and the entire remote pipeline executes and lands a clean &lt;code&gt;data.json&lt;/code&gt; in your Downloads folder. No manual SSH session, no intermediate files left behind on the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Ship It to S3
&lt;/h2&gt;

&lt;p&gt;With AWS CLI configured (&lt;code&gt;aws configure&lt;/code&gt;, or a named profile if you juggle multiple client accounts):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws s3 &lt;span class="nb"&gt;cp&lt;/span&gt; ~/Downloads/data.json s3://your-bucket/backups/data-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d-%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The timestamped key matters — without it, every backup silently overwrites the last one, and you lose the ability to roll back to an earlier snapshot.&lt;/p&gt;

&lt;p&gt;If credentials aren't configured yet, &lt;code&gt;aws s3 cp&lt;/code&gt; fails with &lt;code&gt;Unable to locate credentials&lt;/code&gt;. Fix it with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws configure &lt;span class="nt"&gt;--profile&lt;/span&gt; your-profile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using a named profile instead of the default is worth the extra keystroke if you handle backups for multiple clients or projects — it keeps credential scope explicit instead of relying on whatever happens to be the account's default.&lt;/p&gt;

&lt;h2&gt;
  
  
  The One-Liner, Fully Chained
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;dumpdata&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'ssh username@your-droplet-ip "cd ~/root/project &amp;amp;&amp;amp; docker compose -f docker-compose.production.yml exec -T django python manage.py dumpdata --natural-foreign --natural-primary --exclude auth.permission --exclude contenttypes --exclude admin.logentry --exclude sessions.session --indent 2" &amp;gt; ~/Downloads/data.json &amp;amp;&amp;amp; aws s3 cp ~/Downloads/data.json "s3://your-bucket/backups/data-$(date +%Y%m%d-%H%M%S).json" --profile your-profile'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One command. Dumps remotely, pulls locally, uploads to S3 with a timestamped key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I'd Take It Next
&lt;/h2&gt;

&lt;p&gt;An alias is fine for on-demand backups, but it has no failure handling — if the SSH connection drops mid-stream, or the dump comes back empty, you'll happily upload a corrupted or zero-byte file to S3 without noticing. The next iteration I'm planning is a small bash script with &lt;code&gt;set -euo pipefail&lt;/code&gt;, a file-size sanity check before the S3 upload, and eventually a cron job on the droplet itself so backups happen on a schedule without me remembering to run anything at all.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally documented as part of ongoing DevOps work on the ICVN Tech Studio portfolio infrastructure (cookiecutter-django on DigitalOcean, Cloudflare-fronted).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>django</category>
      <category>aws</category>
      <category>python</category>
      <category>bash</category>
    </item>
    <item>
      <title>Building a Plugin-Free Newsletter Popup on WordPress: Custom REST Endpoint Mailchimp API v3</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Tue, 07 Jul 2026 01:27:45 +0000</pubDate>
      <link>https://dev.to/highcenburg/building-a-plugin-free-newsletter-popup-on-wordpress-custom-rest-endpoint-mailchimp-api-v3-a1m</link>
      <guid>https://dev.to/highcenburg/building-a-plugin-free-newsletter-popup-on-wordpress-custom-rest-endpoint-mailchimp-api-v3-a1m</guid>
      <description>&lt;h1&gt;
  
  
  Building a Plugin-Free Newsletter Popup on WordPress: Custom REST Endpoint → Mailchimp API v3
&lt;/h1&gt;

&lt;p&gt;A client wanted a newsletter popup on their WooCommerce/Divi site to hand out a lead-magnet PDF and grow their Mailchimp audience. The obvious paths — a popup plugin, or Mailchimp's own hosted popup — both lose on the things I actually care about: page weight, brand control, and not loading someone else's JavaScript on every pageview.&lt;/p&gt;

&lt;p&gt;So I built it as a custom component: a modal that posts to my own WordPress REST endpoint, which talks to Mailchimp server-side. No plugin, no Mailchimp front-end script, no reCAPTCHA. Here's the whole thing, including a CSS bug that cost me ten confusing minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not a plugin or the native Mailchimp popup
&lt;/h2&gt;

&lt;p&gt;Three reasons I don't reach for either on a performance-sensitive site:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The hosted Mailchimp popup injects their JS&lt;/strong&gt; and styles into every page. On a site already fighting for Core Web Vitals under Divi, that's one more render-blocking dependency and an off-brand UI I can't fully control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Popup plugins&lt;/strong&gt; carry the same weight problem plus a settings surface, update cadence, and attack surface I don't need for what is, fundamentally, one form and one API call.&lt;/li&gt;
&lt;li&gt;I was going to own the &lt;strong&gt;API integration&lt;/strong&gt; regardless — the site's footer signup wants the same endpoint eventually. Build it once, reuse it.
The tradeoff is that you write the code. For a single form talking to a well-documented API, that's a few hours, and you get exactly the component you want.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;Three moving parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A &lt;strong&gt;REST endpoint&lt;/strong&gt; (&lt;code&gt;namespace/v1/subscribe&lt;/code&gt;) registered in the theme. It receives the email, validates it, and does the Mailchimp call &lt;strong&gt;server-side&lt;/strong&gt; so the API key never touches the browser.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;modal&lt;/strong&gt; rendered once in &lt;code&gt;wp_footer&lt;/code&gt;, gated by page conditionals, with the API key handed to the front end as &lt;em&gt;nothing&lt;/em&gt; — only the endpoint URL and a nonce.&lt;/li&gt;
&lt;li&gt;A small &lt;strong&gt;vanilla-JS controller&lt;/strong&gt; for triggers, the frequency cap, and the fetch.
Everything lives in the child theme. No &lt;code&gt;mu-plugin&lt;/code&gt;, no external assets beyond one CSS and one JS file.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The server side: a REST endpoint that owns the Mailchimp call
&lt;/h2&gt;

&lt;p&gt;The key architectural decision: &lt;strong&gt;the browser never sees the API key&lt;/strong&gt;. The front end posts an email to your endpoint; your endpoint authenticates to Mailchimp. This is the whole reason not to use Mailchimp's client-side embed.&lt;/p&gt;

&lt;p&gt;Register the route and read your credentials from &lt;code&gt;wp-config.php&lt;/code&gt; constants (never hard-code them in theme files that live in version control or the DB):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'rest_api_init'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;register_rest_route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'namespace/v1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/subscribe'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'methods'&lt;/span&gt;             &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'POST'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'permission_callback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'__return_true'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// public; guarded by nonce + honeypot&lt;/span&gt;
        &lt;span class="s1"&gt;'args'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s1"&gt;'required'&lt;/span&gt;          &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'sanitize_callback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'sanitize_email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'validate_callback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'is_email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;'hp'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'sanitize_callback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'sanitize_text_field'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'callback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'np_subscribe'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Mailchimp v3 subscribe is an &lt;strong&gt;upsert&lt;/strong&gt;: &lt;code&gt;PUT /lists/{list_id}/members/{subscriber_hash}&lt;/code&gt;, where the hash is the MD5 of the lowercased email. Using PUT (not POST) means re-submits don't error — they update the existing member instead of throwing "already subscribed."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;np_subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;WP_REST_Request&lt;/span&gt; &lt;span class="nv"&gt;$req&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Honeypot: any value means bot. Fake a success so it doesn't retry.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$req&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'hp'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WP_REST_Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'ok'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;defined&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'MC_API_KEY'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;defined&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'MC_SERVER'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;defined&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'MC_LIST_ID'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WP_REST_Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'ok'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'message'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Newsletter is not configured yet.'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="mi"&gt;503&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$email&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sanitize_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$req&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$hash&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$email&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$auth&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Basic '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;base64_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'user:'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="no"&gt;MC_API_KEY&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'subscribed'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// or 'pending' for double opt-in — see below&lt;/span&gt;

    &lt;span class="nv"&gt;$res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_remote_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s2"&gt;"https://"&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="no"&gt;MC_SERVER&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;".api.mailchimp.com/3.0/lists/"&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="no"&gt;MC_LIST_ID&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"/members/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$hash&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'method'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'PUT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'timeout'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'headers'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s1"&gt;'Authorization'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$auth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'Content-Type'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'application/json'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;'body'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;wp_json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s1"&gt;'email_address'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'status_if_new'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;is_wp_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$res&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WP_REST_Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'ok'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'message'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Try again shortly.'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_remote_retrieve_response_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$res&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Log the real Mailchimp reason server-side; keep the user message clean.&lt;/span&gt;
        &lt;span class="nb"&gt;error_log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Mailchimp '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;': '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nf"&gt;wp_remote_retrieve_body&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$res&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WP_REST_Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'ok'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'message'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Couldn\'t add you right now.'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Tag for segmentation — fire-and-forget, don't block success on it.&lt;/span&gt;
    &lt;span class="nf"&gt;wp_remote_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s2"&gt;"https://"&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="no"&gt;MC_SERVER&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;".api.mailchimp.com/3.0/lists/"&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="no"&gt;MC_LIST_ID&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"/members/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$hash&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/tags"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'timeout'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'headers'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Authorization'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$auth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'application/json'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;'body'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;wp_json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s1"&gt;'tags'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'homepage-popup'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WP_REST_Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'ok'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;base64_encode('user:' . $key)&lt;/code&gt;&lt;/strong&gt; — Mailchimp uses HTTP Basic auth where the username is literally any string and the password is the API key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The server prefix&lt;/strong&gt; (&lt;code&gt;us8&lt;/code&gt;, &lt;code&gt;us21&lt;/code&gt;, etc.) is the suffix on your API key after the dash, and it's part of the API host. Store it as its own constant so the base URL is composed, not hard-coded.
The tag call is deliberately fire-and-forget. If tagging fails, the subscribe already succeeded — I don't want to fail the user's request over a segmentation nicety. Log it and move on.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Single vs double opt-in changes more than a status string
&lt;/h2&gt;

&lt;p&gt;This is the detail that bites people. Mailchimp audiences are configured for &lt;strong&gt;single&lt;/strong&gt; or &lt;strong&gt;double&lt;/strong&gt; opt-in, and your &lt;code&gt;status_if_new&lt;/code&gt; has to match:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Double opt-in&lt;/strong&gt; → send &lt;code&gt;status: pending&lt;/code&gt;. Mailchimp emails a confirmation link; the member isn't subscribed until they click. If you send &lt;code&gt;pending&lt;/code&gt; on a single-opt-in audience, you strand contacts in limbo — no confirmation email is sent, so they sit &lt;code&gt;pending&lt;/code&gt; forever.&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Single opt-in&lt;/strong&gt; → send &lt;code&gt;status: subscribed&lt;/code&gt;. They're live immediately.&lt;br&gt;
But it's not just the API value — &lt;strong&gt;it changes your success copy&lt;/strong&gt;. My first success message said "check your inbox to confirm." On a single-opt-in audience, no confirmation email exists, so that copy is a lie. Match the message to the mode:&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Double: "Almost there — confirm via the email we just sent."&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Single: "You're on the list. Grab your guide below."&lt;br&gt;
Check the audience setting before you write a single line of success-state copy. Single opt-in trades a confirmation step for higher capture at the cost of more typo/bot noise in your list — which is exactly why the next section matters.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Anti-spam: a honeypot, not reCAPTCHA
&lt;/h2&gt;

&lt;p&gt;For a plain email capture, reCAPTCHA is the wrong tool. It adds a third-party script (CWV hit), a privacy footprint, and occasional user friction — all to guard a single input. A &lt;strong&gt;honeypot&lt;/strong&gt; catches the bots that matter here with zero weight and zero UX cost.&lt;/p&gt;

&lt;p&gt;Add a field that's invisible to humans and irresistible to naive bots:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"np-hp"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt; &lt;span class="na"&gt;tabindex=&lt;/span&gt;&lt;span class="s"&gt;"-1"&lt;/span&gt; &lt;span class="na"&gt;autocomplete=&lt;/span&gt;&lt;span class="s"&gt;"off"&lt;/span&gt;
       &lt;span class="na"&gt;aria-hidden=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt; &lt;span class="na"&gt;data-np-hp&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.np-hp&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;absolute&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;-9999px&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it comes back filled, it's a bot — return a fake &lt;code&gt;200&lt;/code&gt; so it doesn't retry against a real error path. Real users never see or touch it. &lt;code&gt;tabindex="-1"&lt;/code&gt; and &lt;code&gt;aria-hidden&lt;/code&gt; keep it out of keyboard and screen-reader flow.&lt;/p&gt;

&lt;p&gt;This won't stop a determined targeted attack, but for newsletter spam it filters the overwhelming majority with none of reCAPTCHA's costs. If abuse ever escalates, you add rate-limiting server-side before you reach for a CAPTCHA.&lt;/p&gt;

&lt;h2&gt;
  
  
  The front end: triggers and a frequency cap
&lt;/h2&gt;

&lt;p&gt;The modal renders once in &lt;code&gt;wp_footer&lt;/code&gt;, gated to the surfaces that should show it and never to logged-in users:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;np_should_render&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;is_user_logged_in&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// members don't need the lead magnet&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;is_front_page&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;is_home&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;is_singular&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'post'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JS fires on &lt;strong&gt;whichever trigger hits first&lt;/strong&gt;: exit-intent, scroll depth, or a time fallback. Exit-intent converts best and annoys least; the timer is just a ceiling.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;delayMs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// e.g. 15000&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mouseout&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;  &lt;span class="c1"&gt;// exit-intent&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientY&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scroll&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;       &lt;span class="c1"&gt;// scroll depth&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollHeight&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientHeight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollPct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;passive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The frequency cap uses &lt;code&gt;localStorage&lt;/code&gt; — once someone sees or submits, don't show it again for N days:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;CAP_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;capDays&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;864&lt;/span&gt;&lt;span class="nx"&gt;e5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;recentlySeen&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;np_seen&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;CAP_MS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrap storage access in try/catch — private-mode and storage-disabled browsers throw, and a newsletter popup should never take down the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mobile: dodge the intrusive-interstitial penalty
&lt;/h2&gt;

&lt;p&gt;Google penalizes intrusive interstitials on mobile that block content on load. A full-screen modal that fires immediately is exactly what they target. Two mitigations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't fire instantly&lt;/strong&gt; — the delay/scroll/exit triggers already push the popup past the initial load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On mobile, render a bottom sheet, not a full-screen takeover.&lt;/strong&gt; It's dismissible, doesn't obscure the whole viewport, and reads as a native mobile pattern.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;600px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;.np&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;align-items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex-end&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nc"&gt;.np__card&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nl"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nl"&gt;max-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;92vh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c"&gt;/* never taller than the viewport */&lt;/span&gt;
        &lt;span class="nl"&gt;overflow-y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c"&gt;/* scroll instead of clipping the CTA */&lt;/span&gt;
        &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12px&lt;/span&gt; &lt;span class="m"&gt;12px&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also dropped the guide-cover image entirely on mobile. In a bottom sheet it fought for vertical space it didn't earn, and a text-only sheet converts fine. Desktop keeps the two-column layout with the cover.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that made it look broken: &lt;code&gt;[hidden]&lt;/code&gt; vs &lt;code&gt;display&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Here's the one that cost me. On submit, the controller hides the form view and shows the success view:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[data-np-view="form"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[data-np-view="success"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After wiring everything up, submitting showed the success message &lt;strong&gt;and&lt;/strong&gt; the form at the same time, with the button stuck on "Sending…". The API had returned &lt;code&gt;200&lt;/code&gt; — the integration was fine. The bug was pure CSS.&lt;/p&gt;

&lt;p&gt;The form view was a two-column flex container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.np__body--split&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;hidden&lt;/code&gt; attribute works by applying &lt;code&gt;display: none&lt;/code&gt; — but at the &lt;strong&gt;user-agent stylesheet's specificity&lt;/strong&gt;, which is effectively zero. My &lt;code&gt;.np__body--split { display: flex }&lt;/code&gt; rule outranks it. So setting &lt;code&gt;.hidden = true&lt;/code&gt; added the attribute, but &lt;code&gt;display: flex&lt;/code&gt; kept winning and the element stayed visible.&lt;/p&gt;

&lt;p&gt;The fix is a one-liner that lets &lt;code&gt;[hidden]&lt;/code&gt; beat the flex rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.np__body--split&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;hidden&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; any time you set an explicit &lt;code&gt;display&lt;/code&gt; on an element you also toggle with the &lt;code&gt;hidden&lt;/code&gt; attribute (or a &lt;code&gt;hidden&lt;/code&gt; class), you have to re-assert &lt;code&gt;display: none&lt;/code&gt; at matching-or-higher specificity. This is a silent, common trap with flex/grid components that get shown and hidden in JS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploy notes
&lt;/h2&gt;

&lt;p&gt;A few things specific to shipping this on managed WordPress hosting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;File-only deploys.&lt;/strong&gt; The popup is entirely code + one image asset — no database rows. So promotion from staging to production is a file push plus two hand-edits (&lt;code&gt;functions.php&lt;/code&gt; include line, &lt;code&gt;wp-config.php&lt;/code&gt; constants). Never push the staging DB for a change like this; you'll clobber live orders and comments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Config per environment.&lt;/strong&gt; The Mailchimp constants live in each environment's &lt;code&gt;wp-config.php&lt;/code&gt; separately. They don't ride along on a file push, and that's correct — you may point staging at a test audience.&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Purge the cache.&lt;/strong&gt; The modal markup is in &lt;code&gt;wp_footer&lt;/code&gt;, so a full-page cache (Varnish/Breeze/Cloudflare) will serve a stale page without it. Purge after deploy and test in a private window.&lt;/p&gt;
&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Keep the API key server-side.&lt;/strong&gt; A custom REST endpoint is a few lines more than the hosted embed and removes an entire class of exposure and page-weight problems.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Match &lt;code&gt;status_if_new&lt;/code&gt; to the audience's opt-in mode&lt;/strong&gt; — and match your success copy to it too.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Honeypot over reCAPTCHA&lt;/strong&gt; for simple captures. Add rate-limiting before you ever add a CAPTCHA.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;[hidden]&lt;/code&gt; loses to any explicit &lt;code&gt;display&lt;/code&gt;.&lt;/strong&gt; Re-assert &lt;code&gt;display: none&lt;/code&gt; on the hidden state of flex/grid components.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Design mobile as a bottom sheet&lt;/strong&gt; to stay clear of Google's interstitial penalty.&lt;br&gt;
The whole thing is one PHP file, one CSS file, and one JS file in the child theme. No plugin, no third-party script, full brand control — and the data lands in the same Mailchimp audience it would have anyway.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>wordpress</category>
      <category>marketing</category>
      <category>api</category>
      <category>php</category>
    </item>
    <item>
      <title>Building a Search Modal for a Membership-Gated WordPress Site</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Thu, 02 Jul 2026 02:36:34 +0000</pubDate>
      <link>https://dev.to/highcenburg/building-a-search-modal-for-a-membership-gated-wordpress-site-b92</link>
      <guid>https://dev.to/highcenburg/building-a-search-modal-for-a-membership-gated-wordpress-site-b92</guid>
      <description>&lt;p&gt;Most "add search to WordPress" tutorials stop at dropping a widget in the header. That's fine until the site has gated content — membership videos, paid courses — and you realize the default search will happily leak the titles of member-only material to logged-out visitors. That's the interesting part of this build, and the part the tutorials skip.&lt;/p&gt;

&lt;p&gt;This is a walkthrough of a live search feature I built for a fitness-membership platform running WordPress, WooCommerce, Divi, LearnDash, and WishList Member. The stack matters, because every one of those plugins had an opinion about how search should behave. The final result: an icon-triggered modal with debounced live results, grouped by content type, access-aware, styled to the brand, and working on mobile.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the problem
&lt;/h2&gt;

&lt;p&gt;The site had four distinct content types worth searching, each with different access rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Blog posts — public&lt;/li&gt;
&lt;li&gt;On-Demand videos — membership-gated&lt;/li&gt;
&lt;li&gt;Courses and lessons — gated via LearnDash + WishList Member&lt;/li&gt;
&lt;li&gt;Store products — public WooCommerce&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the real architectural decision wasn't the UI. It was whether to run one unified index with access-aware filtering, or scope search per content type. I went with unified, enforcing gating at query time so a logged-out user never sees the title or excerpt of member-only content bleeding through results. That single decision drove most of the backend design.&lt;/p&gt;

&lt;p&gt;The UI decision was quicker. The navbar was already dense, so an inline expanding input would have felt cramped at the breakpoint where the menu collapses. An icon that opens a full-screen overlay with live results sidesteps the space problem entirely and reads as more polished.&lt;/p&gt;

&lt;h2&gt;
  
  
  The backend: one REST endpoint
&lt;/h2&gt;

&lt;p&gt;Everything runs through a single custom REST route. This lives in the child theme's &lt;code&gt;functions.php&lt;/code&gt; — deliberately not in a snippets plugin, because search is a hot path and shouldn't depend on a plugin's eval loader staying healthy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'rest_api_init'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;register_rest_route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'rev6/v1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/search'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'methods'&lt;/span&gt;             &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'GET'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'permission_callback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'__return_true'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'args'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'s'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'sanitize_callback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'sanitize_text_field'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'required'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'callback'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;WP_REST_Request&lt;/span&gt; &lt;span class="nv"&gt;$req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$term&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$req&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'s'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;mb_strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$term&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;rest_ensure_response&lt;/span&gt;&lt;span class="p"&gt;([]);&lt;/span&gt;

            &lt;span class="nv"&gt;$args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'s'&lt;/span&gt;              &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$term&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'post_type'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'post'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'product'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sfwd-courses'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="s1"&gt;'posts_per_page'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'no_found_rows'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;];&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;function_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'relevanssi_do_query'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'relevanssi'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                &lt;span class="nv"&gt;$q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WP_Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="nf"&gt;relevanssi_do_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WP_Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// fallback: core search&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="nv"&gt;$out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;get_the_title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="s1"&gt;'url'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;get_permalink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="s1"&gt;'type'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;get_post_type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="s1"&gt;'thumb'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;get_the_post_thumbnail_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'thumbnail'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;rest_ensure_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;function_exists('relevanssi_do_query')&lt;/code&gt; guard is doing more than it looks. Relevanssi is the search plugin that replaces WordPress's weak default search with a proper relevance-ranked index that respects membership access. But wrapping the call in a capability check means the feature degrades gracefully to core WP search if the plugin is inactive, missing, or not yet loaded in a given request context. During the build, an unguarded &lt;code&gt;relevanssi_do_query()&lt;/code&gt; call was fataling the endpoint with a white screen — the guard turns that into a working (if less clever) fallback instead of a broken site.&lt;/p&gt;

&lt;p&gt;That's the general principle: a search endpoint should never be able to take down the page. Degrade, don't die.&lt;/p&gt;

&lt;h2&gt;
  
  
  The access-leak problem
&lt;/h2&gt;

&lt;p&gt;Here's the part worth dwelling on. Relevanssi indexes what you tell it to, and by default it will index gated content and return it in results. On a membership site, that means a logged-out visitor searching for a topic covered only in a paid course sees that course's title and excerpt in the results. That's a content leak — you're advertising exactly what's behind the paywall, with the specifics.&lt;/p&gt;

&lt;p&gt;The fix is enforced server-side, in two layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Configure Relevanssi's index so protected post types respect the membership plugin's visibility rules.&lt;/li&gt;
&lt;li&gt;Where the plugin's filtering isn't clean, add a &lt;code&gt;relevanssi_post_ok&lt;/code&gt; filter to drop posts the current user can't access before they ever reach the response.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The critical rule: &lt;strong&gt;never filter this in JavaScript.&lt;/strong&gt; The overlay hiding a result client-side means the data already left the server — it's in the network response, visible to anyone who opens devtools. Access control that lives in the frontend isn't access control. It has to happen before &lt;code&gt;rest_ensure_response&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This also interacts with caching, which I'll come back to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The frontend: debounced, aborted, escaped
&lt;/h2&gt;

&lt;p&gt;The client is vanilla JS — no framework needed for this. It's enqueued from the child theme with the endpoint URL localized in, rather than hardcoded, so it survives sites where the REST root isn't at the default path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;ctrl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;search&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;debounce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;term&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;term&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;ctrl&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nx"&gt;ctrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;div class="rev6-loading"&amp;gt;&amp;lt;span class="rev6-spinner"&amp;gt;&amp;lt;/span&amp;gt;Searching…&amp;lt;/div&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;REV6_SEARCH&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?s=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;term&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ctrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AbortError&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;p class="rev6-empty"&amp;gt;Something went wrong. Try again.&amp;lt;/p&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things are load-bearing here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Debounce (250ms).&lt;/strong&gt; Without it, every keystroke fires a request. On a slow query that's a pile-up of in-flight fetches racing each other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AbortController.&lt;/strong&gt; When the user keeps typing, the previous request is cancelled. This prevents the classic race where an earlier, slower query resolves &lt;em&gt;after&lt;/em&gt; a later one and overwrites the correct results with stale ones. The &lt;code&gt;if (e.name !== 'AbortError')&lt;/code&gt; check keeps those intentional cancellations out of the error path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Explicit error state.&lt;/strong&gt; A failed fetch used to leave the spinner spinning forever. On a search that can take a couple of seconds, timeouts and dropped requests are likely enough that silent failure is a real UX hole. Now it shows a message and stops.&lt;/p&gt;

&lt;p&gt;The render step groups results by content type so "Articles / Store / Courses" read as sections rather than a flat, confusing list. And the titles get escaped before injection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;escapeHtml&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;&amp;amp;&amp;lt;&amp;gt;"'&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;quot;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;#39;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}[&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not optional. The response strings go into &lt;code&gt;innerHTML&lt;/code&gt;, so an unescaped post titled with an &lt;code&gt;&amp;lt;img onerror=...&amp;gt;&lt;/code&gt; payload would be stored XSS. Escape anything from the API before it touches the DOM as HTML.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loading state, and the thing it was hiding
&lt;/h2&gt;

&lt;p&gt;Adding a spinner was a requested UX fix — the modal sat blank for a beat on slower queries and read as broken. But a spinner treats the symptom. The real question was &lt;em&gt;why&lt;/em&gt; the query took seconds.&lt;/p&gt;

&lt;p&gt;A properly indexed Relevanssi query across a few post types should return in well under 200ms. Multi-second responses almost always mean one of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The index needs rebuilding — a stale or partial index is the number-one cause.&lt;/li&gt;
&lt;li&gt;The query is silently falling through to the core WP fallback, which does an unindexed &lt;code&gt;LIKE&lt;/code&gt; across the post table. On a large table, that genuinely is slow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the spinner shipped, but the actual fix was confirming Relevanssi was active and its index freshly built. Worth checking the endpoint's response time in the network tab before accepting "a bit slow" as normal — the spinner should be covering a 150ms gap, not a 3-second one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caching: the membership leak, again
&lt;/h2&gt;

&lt;p&gt;The site sits behind a Varnish + page-cache stack. This creates a subtle and serious failure mode: if the search endpoint gets cached, one user's access-filtered results can be served to a different user. A logged-in member's search, cached, then served to a logged-out visitor — leaking exactly the gated content the server-side filtering was designed to protect.&lt;/p&gt;

&lt;p&gt;The fix is to exclude the REST route from the page cache entirely. The access filtering only works if every request is evaluated fresh against the current user. This is the same leak from the backend section, wearing a different hat — and it's why the whole "never trust the client, evaluate server-side" principle has to extend to the cache layer too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mobile, and a lesson about fighting the theme
&lt;/h2&gt;

&lt;p&gt;Desktop was straightforward. Mobile was a grind, and the lesson is more useful than the code.&lt;/p&gt;

&lt;p&gt;The trigger started life injected into the nav menu via &lt;code&gt;wp_nav_menu_items&lt;/code&gt;. On desktop, fine. But when Divi collapsed the menu into a hamburger, the injected button got swallowed into the dropdown and — worse — became unclickable. Divi binds its own touch handlers to the collapsed menu items, and they intercepted the tap before the button's handler could run. I tried &lt;code&gt;stopPropagation&lt;/code&gt;, z-index bumps, scoped CSS. Each one was a patch on a symptom.&lt;/p&gt;

&lt;p&gt;The actual fix was to stop fighting the theme's menu. Instead of living &lt;em&gt;inside&lt;/em&gt; the hamburger, the mobile trigger gets injected as a standalone icon in the header, outside the menu entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;mountMobileTrigger&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#rev6-search-mobile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#main-header .container&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#main-header&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.et-l--header .container&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;header&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[search] header not found&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;btn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rev6-search-mobile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aria-label&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Search&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;svg viewBox="0 0 24 24" width="22" height="22" ...&amp;gt;&amp;lt;/svg&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;openOverlay&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because it's no longer a child of the menu, none of Divi's collapsed-menu handlers can eat the tap. It also fixed the real UX problem — search was one tap from any page instead of buried at the bottom of an expanded menu.&lt;/p&gt;

&lt;p&gt;The general takeaway: when a page builder's component keeps resisting your changes, the move is usually to stop modifying its component and place your own element beside it, not inside it. Chained patches against a theme's behavior are a signal you're solving the problem at the wrong layer.&lt;/p&gt;

&lt;p&gt;A couple of smaller mobile details that mattered: the input font-size is set to 16px, because anything smaller triggers iOS Safari's auto-zoom on focus. And the modal is a contained card with side margins and a capped height rather than a full-bleed sheet — a preference call, but it reads as a modal rather than a takeover.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone building this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Decide your gated-content policy first.&lt;/strong&gt; Fully invisible to non-members, or visible-but-locked? That one answer drives the entire index configuration. Everything else is downstream of it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access control is server-side or it doesn't exist.&lt;/strong&gt; Filter before the response, exclude the endpoint from cache, and never rely on the UI to hide anything sensitive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make search un-crashable.&lt;/strong&gt; Guard optional dependencies, fall back to core, and give it an explicit error state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Debounce and abort.&lt;/strong&gt; They're four lines that eliminate whole categories of race conditions and wasted requests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escape everything from the API.&lt;/strong&gt; &lt;code&gt;innerHTML&lt;/code&gt; plus unescaped strings is stored XSS waiting to happen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stop fighting the page builder.&lt;/strong&gt; If you're on your third patch against a theme's component, put your element next to it instead of inside it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The feature itself is small — an icon, an overlay, an endpoint. But most of the engineering was in the parts you can't see: the access filtering, the cache exclusion, the race handling, the graceful degradation. That's usually where the real work is.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>webdev</category>
      <category>php</category>
      <category>showdev</category>
    </item>
    <item>
      <title>The Paywall That Wasn't: Debugging a 919-Video Leak on WordPress</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Tue, 30 Jun 2026 07:31:56 +0000</pubDate>
      <link>https://dev.to/highcenburg/the-paywall-that-wasnt-debugging-a-919-video-leak-on-wordpress-4jae</link>
      <guid>https://dev.to/highcenburg/the-paywall-that-wasnt-debugging-a-919-video-leak-on-wordpress-4jae</guid>
      <description>&lt;p&gt;The ticket was three sentences long: &lt;em&gt;"Some paid members can't play the on-demand videos. They log in, click a video, and still get told they need a membership. Can you fix it tonight?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I fixed it tonight. But the bug I was asked to fix turned out to be the smaller of two problems sitting on the same page — and the bigger one was that the paywall wasn't protecting anything at all.&lt;/p&gt;

&lt;p&gt;This is a write-up of how a routine "members can't log in" ticket unravelled into a full rebuild of the access layer on a WordPress / WooCommerce / WishList Member / Vimeo stack, and the handful of debugging lessons that fell out of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;p&gt;The site is a fitness membership platform. The relevant pieces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WordPress + Divi&lt;/strong&gt; (child theme, Theme Builder for templates)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WooCommerce Subscriptions&lt;/strong&gt; for billing — this is where members actually pay&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WishList Member&lt;/strong&gt; for membership levels and content protection&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;All-in-One Video Gallery (AIOVG)&lt;/strong&gt; for the on-demand library — videos live in a custom post type, &lt;code&gt;aiovg_videos&lt;/code&gt;, with their own taxonomy&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vimeo&lt;/strong&gt; for the actual video hosting (private embeds)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudways / DigitalOcean&lt;/strong&gt; with Varnish full-page cache&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code Snippets&lt;/strong&gt; for small PHP additions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nine hundred and nineteen videos. Hold that number; it matters later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom one: the reported bug
&lt;/h2&gt;

&lt;p&gt;The complaint was straightforward to reproduce. Log in as a real paying member, navigate to a video, and the page shows:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You must have a Membership to access this video.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Annoying, but it reads like a simple access-check bug. My first instinct was the membership level. The site had recently renamed a membership tier, and that's a classic trap: &lt;strong&gt;WishList Member level IDs are stable across renames, but level &lt;em&gt;names&lt;/em&gt; are not.&lt;/strong&gt; Any code or protection rule that matches a level by name silently breaks the moment someone renames it in the admin.&lt;/p&gt;

&lt;p&gt;So the working theory was: the video gate checks for a level by name, the name changed, every member now fails the check. Reasonable. Wrong, as it turned out — but reasonable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom two: the bug nobody reported
&lt;/h2&gt;

&lt;p&gt;Before writing a fix, I checked the obvious counterfactual: what does a &lt;em&gt;non-member&lt;/em&gt; see? I opened the same video in an incognito window, fully logged out.&lt;/p&gt;

&lt;p&gt;The "you must have a membership" notice showed up — and then the video played anyway.&lt;/p&gt;

&lt;p&gt;That reframed everything. The two symptoms were:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Logged-in members&lt;/strong&gt; saw the membership notice (the reported bug).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logged-out visitors&lt;/strong&gt; also saw the notice — &lt;em&gt;but the player rendered and played regardless&lt;/em&gt; (the unreported bug).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Scrolling down on the member's page confirmed it: the "paywall" was a block of text sitting &lt;em&gt;above&lt;/em&gt; a video that rendered unconditionally for everyone. It wasn't a gate. It was a sign that said "members only" taped to an unlocked door.&lt;/p&gt;

&lt;p&gt;The real situation: &lt;strong&gt;all 919 videos were publicly accessible.&lt;/strong&gt; Anyone with a link could watch the entire paid library, logged in or not. The membership notice was decorative, and its own broken logic happened to display it to members too — which is the only reason anyone noticed something was wrong.&lt;/p&gt;

&lt;p&gt;This is the first lesson, and it's the one I keep relearning:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Always check the inverse of the reported symptom.&lt;/strong&gt; "Members are blocked" and "non-members get in" can be the same root cause wearing two masks. The reported half is rarely the dangerous half.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why it was wide open
&lt;/h2&gt;

&lt;p&gt;The videos are an AIOVG custom post type. WishList Member's content protection was configured on the &lt;em&gt;parent&lt;/em&gt; On-Demand page, with "automatically apply protection to child content" enabled.&lt;/p&gt;

&lt;p&gt;But WLM's cascade follows the WordPress page/post hierarchy. The AIOVG videos are a separate custom post type — not children of that page in any way WLM understands. So the cascade never reached them. The parent page looked protected in the admin; the 919 videos under it were never touched.&lt;/p&gt;

&lt;p&gt;Two systems, each assuming the other had it handled, and the content fell through the gap between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: one gate, server-side, before render
&lt;/h2&gt;

&lt;p&gt;The instinct with a Divi site is to reach for the template and conditionally hide the player. Don't. Hiding markup client-side still ships the source URL in the response — the player's gone visually, the video link isn't. And template-level conditionals are fragile across the half-dozen ways a CPT can get rendered (archive autoplay, shortcodes, REST).&lt;/p&gt;

&lt;p&gt;The correct interception point is &lt;code&gt;template_redirect&lt;/code&gt;, early, before AIOVG or Divi build anything. If the visitor isn't entitled, you redirect and &lt;code&gt;exit&lt;/code&gt; — the player HTML never enters the response at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'template_redirect'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nf"&gt;is_singular&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'aiovg_videos'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nv"&gt;$uid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_current_user_id&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$uid&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;user_can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'manage_options'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// admins always pass&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$can_view&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$uid&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;function_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'wcs_user_has_subscription'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;wcs_user_has_subscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$can_view&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;is_user_logged_in&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;wp_safe_redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;home_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'/membership/'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// logged in, no sub&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;wp_safe_redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;add_query_arg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'redirect_to'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;urlencode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;get_permalink&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;home_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'/login/'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// logged out — return to the video after sign-in&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the gate keys on the post type (&lt;code&gt;is_singular('aiovg_videos')&lt;/code&gt;), it covers all 919 videos at once. New uploads are gated on publish. There's no per-video work, ever.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision that fixed the &lt;em&gt;reported&lt;/em&gt; bug: stop trusting the level
&lt;/h2&gt;

&lt;p&gt;Notice what the gate checks: &lt;code&gt;wcs_user_has_subscription()&lt;/code&gt;, not a WishList Member level.&lt;/p&gt;

&lt;p&gt;This is the actual fix for "paid members can't play." The original setup gated on WLM &lt;strong&gt;levels&lt;/strong&gt;, and the allowed-level list didn't include every level paying members were actually on. A member subscribed and paying — but sitting on a level that wasn't in the approved list — got rejected. The rename made it worse, but the deeper problem was architectural:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Your billing truth and your access truth were two different systems.&lt;/strong&gt; Members pay in WooCommerce. Access was checked in WishList Member. Those only stay in sync if every Woo→WLM hook fires perfectly, forever. They don't.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Gating on the &lt;strong&gt;source of truth&lt;/strong&gt; — does this person have an active subscription? — sidesteps the entire sync problem. It also matched the business rule once I confirmed it with the client: &lt;em&gt;no tier should be blocked from videos.&lt;/em&gt; "Has an active subscription" is exactly that rule, expressed in code, and it's immune to level renames because it never looks at levels.&lt;/p&gt;

&lt;p&gt;Once the snippet was the sole authority, I disabled WLM's content protection on the video CPT and the On-Demand pages. &lt;strong&gt;Two gates fighting each other is how you get a member blocked by one while the other tries to let them in.&lt;/strong&gt; Pick one source of truth and delete the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 500 that wasn't in the log I was reading
&lt;/h2&gt;

&lt;p&gt;Mid-debugging, the video pages started throwing &lt;code&gt;There has been a critical error on this website&lt;/code&gt; — a PHP fatal, while I was logged in as admin.&lt;/p&gt;

&lt;p&gt;I pulled the Cloudways logs and found... nothing useful. Pages of &lt;code&gt;the log buffer is full (1024)&lt;/code&gt; truncation warnings (cosmetic, caused by my own long admin-search URLs) and &lt;code&gt;directory index forbidden&lt;/code&gt; 403s (Googlebot hitting locked directories — security working as intended). Noise.&lt;/p&gt;

&lt;p&gt;The lesson here is unglamorous but cost me time:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A "critical error" is a PHP fatal, and PHP fatals go to the PHP error log — not the Nginx access/error log.&lt;/strong&gt; I was reading the web-server log looking for an application error. Wrong file. &lt;code&gt;WP_DEBUG_LOG&lt;/code&gt; writing to &lt;code&gt;wp-content/debug.log&lt;/code&gt; is the fast path to the actual fatal line.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The fatal was a WishList Member function that existed (so my &lt;code&gt;function_exists()&lt;/code&gt; guard passed) but threw when invoked on that version. The clean resolution was to remove the WLM call from the gate entirely — which I wanted to do anyway, since I'd already moved access onto WooCommerce. The fatal disappeared because the code that could fatal was gone, not because I patched around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The edge cases that "done" hid
&lt;/h2&gt;

&lt;p&gt;With the gate live and three test accounts passing (admin, a paying member, a logged-out visitor correctly bounced), it looked finished. It wasn't. The membership model had more shape than the ticket implied, and each wrinkle was a chance to wrongly lock out a paying customer.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Multiple subscription tiers, one rule.&lt;/strong&gt; All-Access, On-Demand, and Vitality Individual are all WooCommerce subscriptions. Gating on "any active subscription" covered all three without enumerating them — which is the point of gating on the source of truth rather than a list you have to maintain.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A legacy label.&lt;/strong&gt; A member on an old "Gold Membership" level got redirected. The client clarified that "Gold" was simply the &lt;em&gt;old name&lt;/em&gt; for the All-Access membership — these are current, paying members. The redirect was a bug, not correct behavior.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A non-"active" status.&lt;/strong&gt; When I checked that member in WooCommerce → Subscriptions, the subscription existed but its status was &lt;strong&gt;on-hold&lt;/strong&gt; (a failed or retrying payment), not &lt;code&gt;active&lt;/code&gt;. My gate counted only &lt;code&gt;active&lt;/code&gt;, so it bounced her. Given the client's explicit priority — &lt;em&gt;"first make sure members have access, then worry about locking others out"&lt;/em&gt; — I widened the accepted statuses to include &lt;code&gt;on-hold&lt;/code&gt; and &lt;code&gt;pending-cancel&lt;/code&gt;, the grace-period states where someone is reasonably still a paying member:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$can_view&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
       &lt;span class="nf"&gt;wcs_user_has_subscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;wcs_user_has_subscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'on-hold'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;wcs_user_has_subscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'pending-cancel'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last one is worth flagging as a &lt;em&gt;product&lt;/em&gt; decision, not just a code one: including &lt;code&gt;on-hold&lt;/code&gt; means a member whose card permanently fails keeps access until the subscription fully cancels. That's a deliberate "members-first" trade, and the kind of thing you surface to the client rather than bury as a silent default.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;"It works for my three test accounts" is not "it works."&lt;/strong&gt; The accounts that break your assumptions are the ones with unusual &lt;em&gt;status&lt;/em&gt;, not unusual &lt;em&gt;identity&lt;/em&gt; — the on-hold member, the legacy tier, the trialing user. Go find those specifically.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The part the page gate doesn't solve
&lt;/h2&gt;

&lt;p&gt;There's a difference between a &lt;strong&gt;UX paywall&lt;/strong&gt; and a &lt;strong&gt;security paywall&lt;/strong&gt;. The &lt;code&gt;template_redirect&lt;/code&gt; gate stops the player from rendering — but it protects the &lt;em&gt;page&lt;/em&gt;, not the &lt;em&gt;file&lt;/em&gt;. If the underlying video URL is public, a leaked direct link still plays, gate or no gate.&lt;/p&gt;

&lt;p&gt;Here the videos are on &lt;strong&gt;Vimeo as private embeds&lt;/strong&gt;, with a privacy hash in the URL. That's meaningfully better than raw public MP4s: the gate already stops non-members from reaching the page and grabbing the link in the first place. To fully close it — so a &lt;em&gt;copied&lt;/em&gt; link won't play anywhere else — the remaining step is Vimeo's &lt;strong&gt;domain-level privacy&lt;/strong&gt;: restrict embedding to the site's own domain. Then even a leaked URL refuses to play off-site.&lt;/p&gt;

&lt;p&gt;That's a settings change, not a build — but it requires access to the Vimeo account, which is the client's to grant. The honest version of "done" names what's closed and what's pending, rather than letting "the player is hidden" be mistaken for "the content is secure."&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take to the next one
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check the inverse of the reported symptom before writing any fix.&lt;/strong&gt; The reported half is rarely the dangerous half.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gate on the source of truth.&lt;/strong&gt; Bill in one system, check access in that same system. Downstream copies drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One gate, not two.&lt;/strong&gt; Two access systems on the same content will eventually disagree, and the disagreement is always a blocked customer or a leaked file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the right log.&lt;/strong&gt; PHP fatals are in the PHP log, not the web-server log.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Intercept before render, and &lt;code&gt;exit&lt;/code&gt;.&lt;/strong&gt; Hiding markup isn't protection if the source URL still ships.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test by status, not by identity.&lt;/strong&gt; On-hold, legacy, trialing — the weird states are where paying members get wrongly locked out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distinguish the UX paywall from the security paywall&lt;/strong&gt;, and tell the client which one they actually have.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The ticket said "some members can't play videos." What it meant was "our entire paid catalog has been free for anyone with a link, and the only reason we noticed is that the broken sign was also annoying the people who paid." Both true. The second framing is the one worth fixing for.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>webdev</category>
      <category>security</category>
      <category>php</category>
    </item>
  </channel>
</rss>
