Introduction: a project where we had been cautious about major upgrades
The project in question is a WordPress e-commerce site that has run on the same stack for a long time. Its product listings have an end time (inventory disappears as time runs out), and it carries a paid plugin (UAP / Ultimate WP Auction), a custom child theme (based on Flatsome, with 7 overridden WC templates), and multilingual support (Japanese / English) via WPML. Unlike a typical store, it has to show auction status (current bid, time remaining, ended or not) accurately, so excluding product-related pages from the Kinsta page cache is a hard operational requirement.
We had been cautious about major upgrades because the compatibility-testing cost of this combination and the risk of the custom code breaking the layout were both high. Bump WC by a major version and the child theme's overridden templates stay stuck on assumptions from the old core and break the layout, UAP's behavior changes, WCML falls apart ── testing all of that at once was simply too much effort for what it would buy us, and that situation dragged on. As a result, WC sat at 4.5.5 (released in 2020) and UAP at 2.3.2 for a long time.
This article is about switching to a workflow where Claude Code runs WP-CLI over SSH and moving WooCommerce from 4.5.5 to 10.0.4 (6 major versions) step by step. It is not a story about Claude Code on its own, but about an operational design that combines the Kinsta Staging snapshot safety net, a division of labor that keeps Phase state in CLAUDE.md / docs/tasks/, and a boundary where a human gives the final approval.
It is written for developers and maintainers struggling with major upgrades on long-running WordPress projects, and for engineers looking for a concrete pattern for bringing AI into maintenance work.
1. Why WordPress major version upgrades tend to stall
The reasons major upgrades on WordPress projects tend to stall can be broken down into three layers: technical, economic, and organizational. This section walks through them in turn.
1-1. What piling up "not touching it is the right call" leads to
Even if you stop updating WP, plugins, and themes, the site appears to keep working. "If it works, don't touch it" empirically has the lowest incident rate, and at first it is a rational decision.
Over time, though, the following quietly accumulates: deprecation warnings multiply with each PHP major release (plenty of them going from PHP 7.x to 8.x); unpatched vulnerabilities pile up as WC and plugins ship security fixes you are not applying; the interfaces between the old bundled WC and newer plugins drift slightly, breaking small details of the layout; and inefficient patterns in the old code surface as the number of products and categories grows. Even if you keep choosing not to touch anything, the WP core / PHP / plugin ecosystem around you keeps moving, so a hallmark of WordPress maintenance is that leaving things alone does not actually preserve the status quo.
1-2. Why testing cost > benefit feels true
The decision to freeze major upgrades shows up the moment the perceived cost of testing exceeds the benefit. With WP, the following typically stacks up:
- Compatibility checks for paid plugins ── vendors don't always support the latest WC right away, so you have to guess which combinations are safe on your own
- Testing the custom child theme ── overridden templates drift with every core update, and only an experienced developer can track the diffs accurately
- A wide UAT scope ── on an e-commerce site you need to run through product listings, product pages, cart, checkout, My Account, the admin, and outgoing emails
Running through all of that "every time you bump a major version" or "every time you bump a minor version" is realistically heavy, so "we'll do it all at once, someday" becomes the norm. And "someday" usually keeps receding.
1-3. Where decisions stall when one person carries it all
Organizational factors matter too. WP maintenance projects tend to end up with a single owner, and that alone can stall upgrade decisions: no one to review the upgrade plan, no one to talk through the rollback procedure if it fails, no shared progress checkpoints ── the absence of a sounding board is what slows decisions down.
With technical, economic, and organizational layers stacked on top of each other, major upgrades stall almost by design. Put the other way around, if you apply a concrete approach to each of these three layers, you get closer to maintenance that doesn't stall. The next section lays out those concrete measures.
2. What changes when Claude Code works on WordPress over SSH
Against the three-layer structural problem from Section 1, here is what changes when Claude Code works on WordPress over SSH, from three angles.
2-1. Claude Code can run WP-CLI directly
WordPress maintenance has traditionally meant clicking around the admin (WP Admin), and that comes with two problems ── there is almost no history of what was done ("when, who, and which setting of which plugin was changed" is a black box), and it is hard to automate (repeat the same task on 10 sites and the effort grows linearly).
WP-CLI is WordPress's server-side command line, and it solves both. When Claude Code runs WP-CLI over SSH, "clicking through the admin" becomes "one command."
$ wp plugin update woocommerce --version=7.2.1
$ wp wc tool run db_update_routine
$ wp transient delete --allIn fact, every plugin update command in the WooCommerce 4.5.5 → 10.0.4 (6 major versions) upgrade described here went through the CLI.
2-2. Work logs and diffs are left behind naturally
When you work over SSH/CLI, the following is left behind naturally: the terminal scrollback keeps a complete record of what was executed, any file you rewrote shows what changed via git diff, and failed commands remain along with their output (error messages).
For years the WP world has treated "no change history" as normal, but if you run things with SSH + WP-CLI + Git, the starting point for any investigation ("when, why, and who changed that setting?") is always at hand. Combined with Claude Code's session logs, it also gives you a traceable log of human + AI collaboration.
2-3. Phase design lets you move in small steps
The biggest change is that the operating philosophy shifts from "do everything at once" to "move forward Phase by Phase." The traditional way is "apply every update at once in a test environment → one big integration test → ship to production if it works, roll back everything if it doesn't." A Phase design instead breaks it into steps ── "Phase 0 (prep), Phase 1 (upgrade only plugin X), Phase 2 (WC by just one major version)..." ── and takes a Kinsta snapshot before and after each Phase so you can always roll back.
The state of each Phase is recorded as a checklist in docs/tasks/<task>.md. In the next session, Claude Code just reads that file and immediately knows "we're done up to here, next is Phase X," so long-running work can be done in small chunks. The missing-reviewer problem of "carrying it alone" from 1-3 is also partly filled by Claude Code, via the Phase file and session logs.
3. The three-piece kit for handing WordPress to Claude Code, plus Kinsta Staging
Now for the concrete building blocks for bringing Claude Code into WordPress maintenance. It is a combination of a three-piece kit (CLAUDE.md / docs/tasks/ / auto memory) and the prerequisite that supports it (Kinsta Staging).
3-1. CLAUDE.md: project assumptions, fixed flows, and prohibitions
CLAUDE.md is a file Claude Code always reads at the start of a session, placed at the project root. Its biggest payoff is that you no longer have to explain from scratch every time "what assumptions this project works under."
- Project assumptions (WP / WC versions, Kinsta setup, PHP version, list of plugins in use)
- Production deployment flow (Files-only Push, dashboard operations, final human approval)
- Prohibitions (Claude Code is not allowed direct SSH to production, no direct edits to wp-config.php, etc.)
- Email tone and client communication policy
- Emergency procedures (rollback, support escalation contacts)
On this project, CLAUDE.md pins down rules such as "access to Kinsta Staging only," "production deploys are a Files-only Push run manually by a human," and "the procedure for moving the mu-plugin out of the way before a production deploy."
3-2. docs/tasks/<task>.md: tracking progress with Phases and checkboxes
For long-running work, we create a Markdown file under docs/tasks/ and keep state as a per-Phase checklist. The WooCommerce upgrade on this project lived in docs/tasks/woocommerce-uap-upgrade.md, with every task from Phase 0 to Phase 6 tracked as a checkbox.
With this file in place: (1) in the next session Claude Code immediately knows "Phase 3 is done, next is Phase 5," (2) state isn't lost when work is paused and resumed, and (3) even as a solo operator, your "past self" acts as the reviewer (what gets reviewed is the completion criteria of the previous Phase). It structurally eliminates the "redoing work because you forgot" problem in long-running tasks.
3-3. Auto memory: accumulating collaboration norms
Claude Code has an auto memory feature that lets you build up "collaboration norms" per user. For example, once you have recorded things like "work on staging, never touch production directly," "respond in Japanese," "always confirm before risky operations," and "commit message style," they are applied automatically without having to instruct them every session. Context stays consistent even as the engagement runs long.
3-4. Built on Kinsta Staging: break it, roll it back
The three-piece kit only works because Kinsta Staging gives you a sandbox you can break and roll back. Kinsta Staging lets you create a full copy of production in one click, restore to any point in time from a snapshot, and push "Staging → Live" as Files only (protecting the production DB).
Granting Claude Code SSH access isn't scary precisely because you can always roll back. Put the other way around, turning an AI loose on an environment you can't roll back simply doesn't fit the approach described here.
4. Case study: WooCommerce 4.5.5 → 10.0.4 (6 major versions)
The main work on this project was moving WooCommerce 4.5.5 and the paid plugin UAP 2.3.2 step by step to 10.0.4 / 2.4.4 respectively. We split it into Phases 0 to 6 and took a Kinsta snapshot before and after each Phase to guarantee we could roll back.
4-1. Phase design and the big picture
To replace "do everything, then test" with "test incrementally," we broke the major upgrades into the smallest possible units. Here are the Phases we actually used.
| Phase | Name | Main tasks |
|---|---|---|
| 0 | Prep | Take a Kinsta snapshot, check WCML status, disable WP Mail SMTP, review changelog highlights for each WC minor version |
| 1 | UAP 2.3.2 → 2.4.4 | Update only UAP while keeping WC at 4.5.5. Verify site responses and WCML behavior |
| 2 | WC 4.5.5 → 7.2.1 | wp plugin update woocommerce --version=7.2.1, run db_update_routine and Action Scheduler. Fix the undefined $uwa_countdown_format warning in the child theme, diff the 7 overridden templates against core, rebase uwa-bids-history.php onto UAP 2.4.4 |
| 3 | WC 7.2.1 → 10.0.4 | wp plugin update woocommerce --version=10.0.4, keep HPOS OFF, fix the StoreApi fatal error with wp transient delete --all, all smoke tests pass |
| 4 | ─ | Not in the original plan; number skipped after the UAP test scope was widened to cover 7.2.1 → 10.0.4 |
| 5 | Testing + clearing warnings | Via CLI: create 3 test users, insert bid logs, check bid history display, confirm winning-bid emails are blocked, delete test data. In parallel, reduce outdated template warnings from 35+ to 1 |
| 6 | Production deploy | Run Kinsta "Staging → Live" as Files only, move staging-mail-disabled.php out of the way, then click the WC database update button in the production admin after the push |
Phase 4 is missing because it didn't exist in the original plan; when we widened the UAP test scope to WC 10.0.4, we kept the existing numbering as is. It has no implementation significance.
4-2. Porting the diffs of 7 child theme files
The Flatsome child theme (flatsome-child) had 7 WooCommerce template overrides under woocommerce/, and these were the source of outdated template warnings after every WC core update. In Phase 5 we diffed them against the core versions in WC 10.0.4 / Flatsome parent 3.20.6 and rebased them by porting only the custom code we actually needed onto the latest versions.
| File | What the customization does |
|---|---|
| archive-product.php | The whole product archive (delegates to Flatsome's layouts/category) |
| content-product.php | Card inside the product loop (rewritten as an auction-specific card) |
| layouts/category.php | Category page layout |
| myaccount/form-login.php | Login form (Remember Me checked by default, Flatsome wrapper preserved) |
| single-product/related.php | Related products (empty override to remove them) |
| single-product/tabs/sections.php | Tab structure |
| single-product/tabs/uwa-bids-history.php | UAP bid history (auto-bid column replaced with a ※ mark) |
The rebase followed a single rule: "start from the core version and patch in our own divergences in the smallest possible units." For example, content-product.php went from @version 3.6.0 → @9.4.0 and archive-product.php from @version 3.4.0 → @8.6.0, and in each case we left a divergence note in the docblock during the rebase. We also committed master copies to the repository under scripts/wc-template-overrides/ so they can be redeployed even if a staging resync wipes them.
4-3. The StoreApi fatal error during the WC 10.0.4 migration and transient delete --all
Right after Phase 3 (WC 7.2.1 → 10.0.4), product pages and the admin went down with errors along the lines of Fatal: woocommerce-blocks/StoreApi/deprecated.php not found. The cause was a stale class map that jetpack-autoloader had cached in a WP transient, which no longer matched WC 10.0.4's file layout.
Deleting all transients with WP-CLI is all it takes.
$ wp transient delete --allThat single command made the fatal error go away, and every smoke test afterward passed. When a WC major upgrade changes the structure under StoreApi / Blocks, suspecting the state of transients and the object cache first is the fastest route.
4-4. Outdated template warnings: 35+ → 1
WooCommerce's System Status (Admin → WooCommerce → Status → Templates) flags templates overridden with a structure older than the bundled core version as outdated template warnings. When we started Phase 5, this project had more than 35 of them in total.
| Category | Before | After | What we did |
|---|---|---|---|
flatsome-child (child theme) | 4 | 0 | Rebased each file onto WC 10.0.4 core, updated @version, moved divergence notes into the docblock |
flatsome (parent theme) | 30+ | 0 | Updated the Flatsome parent theme from 3.11.3 to 3.20.6 (via ThemeForest) |
booster-plus-for-woocommerce | 1 | 1 | No update available from the plugin. No immediate fix short of forking it into the child theme |
| Total | 35+ | 1 |
The one remaining warning comes from a template bundled with the booster-plus-for-woocommerce plugin, for which upstream has not shipped an update. We could fork and rebase it in the child theme, but that would mean risking a conflict with every future plugin update, so for now we accept "one warning remaining."
That same booster-plus-for-woocommerce also fires a flood of PHP 8.2 "Creation of dynamic property" deprecations, with roughly 700 captured per product page load (most of them from this plugin). It doesn't affect current functionality, but since these are slated to potentially become fatal in PHP 9, the long-term maintenance homework is concentrated in this one plugin.
5. Side benefit: we fixed performance regressions along the way
While Claude Code was re-reading the code during the upgrade, two categories of performance problems came to light: PHP execution cost (a large number of unnecessary WP_Query calls on product pages) and caching strategy (even static pages that could be served from the edge were bypassing the cache). We reworked both alongside the upgrade, and a re-measurement today (2026-05-12) confirmed -42% queries / -30% peak memory / -37% PHP generation time for a single product page load. On top of that, we rewrote four years of unnecessary cache bypassing on static pages into a declarative implementation.
5-1. What was slowing things down
We had Claude Code read the Query Monitor output and go through the SQL running on the product page one query at a time, and it found two categories of processing that were functionally unnecessary.
- UAP's category count filter ── a routine that tallies stock counts by firing
WP_Query × 3per product category (three variants of fetching all products minus one) was also running on product pages. A product page only needs the one product being viewed, yet count queries were running for every category that product belongs to. - Empty queries for related products / upsells / cross-sells ── even though the child theme had an empty override of
single-product/related.php(by design, no related products are shown), the underlyingWP_Querywas still running through another code path. It is the classic "half-removed" pattern: nothing shows up on the page, but the DB still gets hit.
Both were safe to stop without changing any functionality, and together they added more than 200 extra queries to a single product page load.
5-2. Stopping them with an mu-plugin, without changing functionality
We added an mu-plugin that suppresses both of the above only on product pages and static pages. Our priorities were firing it on an is_singular() condition rather than globally, not having to touch any plugin settings screens, and keeping it under version control. Here is the actual code:
<?php
/**
* Plugin Name: WC Product Page Performance
* Description: Performance mu-plugin. Short-circuits WC queries for sections the
* child theme hides, and drops UAP's category count filter on single product pages.
* Author: MOOBON
* Version: 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) exit;
// 1. Suppress related / upsell / cross-sell queries
// The child theme empties related.php, so there is no need to fetch them at all.
// Upsells / cross-sells are not used either, so return empty arrays
add_filter( 'woocommerce_product_related_posts_query', function() {
return [];
}, 10, 1 );
add_filter( 'woocommerce_product_get_upsell_ids', function( $ids ) {
return [];
}, 999 );
add_filter( 'woocommerce_product_get_cross_sell_ids', function( $ids ) {
return [];
}, 999 );
add_filter( 'woocommerce_get_related_product_cat_terms', function() {
return [];
}, 10 );
add_filter( 'woocommerce_get_related_product_tag_terms', function() {
return [];
}, 10 );
// 2. Disable UAP's category count rewrite filter on single product pages
// That filter fires WP_Query x 3 (fetching all products minus 1) per category,
// wasting 30+ queries and 100+ ms on product pages.
// Category counts still appear in the product page sidebar,
// but we chose perceived speed over precise numbers
add_action( 'wp', function() {
if ( is_singular( 'product' ) || is_singular( 'page' ) ) {
// UAP registers the filter via a class instance, so remove it through the object
if ( class_exists( 'UWA_Front' ) ) {
global $wp_filter;
if ( isset( $wp_filter['get_terms'] ) ) {
foreach ( $wp_filter['get_terms']->callbacks as $priority => $callbacks ) {
foreach ( $callbacks as $id => $cb ) {
if ( is_array( $cb['function'] ) && is_object( $cb['function'][0] )
&& get_class( $cb['function'][0] ) === 'UWA_Front'
&& $cb['function'][1] === 'uwa_change_count_product_category' ) {
unset( $wp_filter['get_terms']->callbacks[$priority][$id] );
}
}
}
}
}
}
}, 1 );UAP registers its filter with add_filter() using a class instance method, so a plain remove_filter() won't remove it. We strip it with the classic approach of scanning $wp_filter and matching on the class and method names (this depends on the plugin's internals, and we accept that a UAP update could break it).
It lives at wp-content/mu-plugins/wc-product-page-perf.php (for the same reasons as staging-mail-disabled.php ── it can't be deactivated from the admin, and it is less likely to disappear on a resync). A master copy sits in the repository so it can be redeployed after a staging resync.
5-3. Results: -42% queries / -30% memory / -37% PHP generation time
To coincide with writing this article, we re-measured on staging today (2026-05-12). This compares a single product page load with the perf mu-plugin OFF vs. ON.
Test conditions
- Target URL: one product page (fixed URL)
- Language: Japanese (
/ja/) - Login state: logged in as admin for Query Monitor measurements, anonymous (via curl) for TTFB measurements
- Cache: first load (MISS) after Kinsta page cache flush +
wp transient delete --all+wp cache flush - Tools: Query Monitor v4.0.6 (Queries / Memory / Page Generation / DB Query Time), curl (median TTFB of 5 samples)
- Sample size: 1 sample per variant for QM, 5 samples for TTFB
| Metric | Variant A (perf OFF) | Variant B (perf ON) | Difference |
|---|---|---|---|
| Total Queries | 489 | 285 | -204 (-42%) |
| Peak Memory | 186.5 MB | 130.8 MB | -55.7 MB (-30%) |
| Page Generation | 2.02s | 1.27s | -0.75s (-37%) |
| DB Query Time | 0.23s | 0.04s | -0.19s (-83%) |
| Anonymous TTFB (median) | 1.37s | 1.10s | -0.27s (-20%) |
The DB Query Time difference (-83%) stands out, and it is simply what disappeared once the flood of unnecessary WP_Query calls on the Variant A side was gone. Total Queries dropped by roughly half, and the per-query execution cost was trimmed further as well.
Anonymous TTFB (-20%) is more modest than the other metrics because the PHP execution cost difference gets diluted once responses are served from Kinsta's page cache or Cloudflare's edge. However, as mentioned in the introduction, because this is an auction site we disable the page cache for product-related pages as a rule, so real production requests are nearly always a "cache MISS = PHP runs every time." In other words, the -37% in PHP generation time translates directly into the speed production users actually feel ── and that was the main goal of this optimization.
5-4. Another side benefit: from "disable caching globally" to "declaratively scoped"
We also cleaned up the implementation of the "disable page cache on product-related pages" logic mentioned in the introduction as part of the upgrade. This is a side benefit on a different axis from PHP execution cost: it resolved a four-year-old leftover where every page, static ones included, bypassed the cache.
Old implementation (2022-04 to 2026-05-10): a hack that force-issued the wordpress_logged_in_ cookie
The old implementation, embed_login_cookie.php in the child theme, issued a wordpress_logged_in_* cookie to every visitor (including anonymous ones). By convention, WordPress / Kinsta / Cloudflare don't serve cached pages to logged-in users, so the hack exploited that behavior: this cookie present = treated as logged in = cache bypassed across the entire site.
It did keep auction information real-time, but static pages such as the company profile, return policy, contact form, and account registration were bypassed too, leaving the site at a TTFB of ~1s with PHP running on 100% of requests for four years.
New implementation (2026-05-11 onward): declarative, URL-based scoping
The new implementation hooks into template_redirect, checks both the URL path and WC conditionals, and sends nocache_headers() only on auction-related pages (the master copy in the repository is scripts/embed_login_cookie.php.master.php).
add_action( 'template_redirect', function () {
// 1. URL path check (for the non-standard layout)
$req = isset( $_SERVER['REQUEST_URI'] ) ? (string) $_SERVER['REQUEST_URI'] : '';
$path_matches_auction_area =
strpos( $req, '/shop' ) !== false ||
strpos( $req, '/product' ) !== false ||
strpos( $req, '/auction' ) !== false;
// 2. Also check the standard WC conditionals
$is_auction_info_page =
is_front_page() ||
is_home() ||
is_shop() ||
is_product() ||
is_product_category() ||
is_product_tag() ||
is_product_taxonomy();
// send no-cache headers if either matches
if ( $path_matches_auction_area || $is_auction_info_page ) {
nocache_headers();
}
}, 1 );nocache_headers() is a WordPress core function that sends three headers, including Cache-Control: no-cache, must-revalidate, max-age=0, no-store, private. Kinsta's edge doesn't cache a response when it sees this no-cache, so the end result is a clean split: "auction information is real-time, everything else can be edge-cached."
Design points
- Don't rely on
is_shop()alone ── this project has a non-standard WC layout: the "Shop page" is assigned to the front page/ja/, while/ja/shop/is a separate static page. Sinceis_shop()doesn't always behave as expected, we also check whether the URL path contains/shop/product/auction - Priority 1 on
template_redirect── it runs first, after WC conditionals are resolved but before any HTML output, sono-cacheis locked in before a later hook can send headers. WC itself sends its ownnocache_headers()on cart / checkout / My Account, so we leave those alone - You can't verify the effect on staging ── Kinsta disables the page cache site-wide on staging environments, so the effect of this implementation can't be observed there. It only becomes visible after the Phase 6 production deploy
Before/After by page type
| Page type | Old implementation | New implementation |
|---|---|---|
Home /ja/ | Bypass | Bypass (unchanged) |
Product listing /ja/shop/ | Bypass | Bypass (unchanged) |
Product page /ja/product/xxx | Bypass | Bypass (unchanged) |
Category /ja/product-category/xxx | Bypass | Bypass (unchanged) |
Company profile /ja/about/ | Bypass (wasteful) | Edge-cacheable |
Return policy /ja/refund_returns/ | Bypass (wasteful) | Edge-cacheable |
Account registration /ja/account-register/ | Bypass (wasteful) | Edge-cacheable |
| Cart / Checkout / My Account | Bypass | Bypass (sent by WC itself) |
Auction information is still delivered in real time, while static content is served from the edge after the first PHP run. The goal is a significant TTFB reduction for pages like the company profile, return policy, and account registration, which had been bypassed wholesale for four years. We plan to watch the change in hit rate in Kinsta's cache-perf logs after the production deploy.
The essence of this rewrite is that we replaced a "kill the cache globally" hack with declarative, URL-based scoping. When new pages are added in the future, you can predict the behavior just by reading the code ── whether that URL is auction-related or static ── so the operational cognitive load went down at the same time.
6. Side benefit: we also eliminated the risk of accidental emails
Because this project's product listings have an end time, copying the production DB to staging carried the risk that emails triggered by end timers (winning-bid notifications and the like) would reach real customers. As part of the upgrade work, we redesigned this with layered defenses.
6-1. The risk structure specific to staging
Kinsta Staging starts from a copy of the production DB. The customer tables come along as is, so if wp-cron runs on staging and triggers the email routine for listings past their end time, notifications go out to real customers' email addresses. The assumption that "it's staging, so it's safe" easily breaks down with the combination of end timers × wp-cron.
Even if you deactivate WP Mail SMTP in the admin, fallback sending via PHP's mail() or direct sending from other plugins remain as loopholes. Add "forgot to disable it" and "settings revert on resync," and a first line of defense alone can't fully rule out an incident.
6-2. Layered defense with a pre_wp_mail short-circuit mu-plugin
As a second line of defense, we added an mu-plugin that short-circuits the wp_mail() call itself with the pre_wp_mail filter (WP 5.7+). The filter runs at a layer before the actual send, so whether it is WP Mail SMTP or PHP mail(), it catches every message sent through wp_mail().
<?php
/**
* Plugin Name: Staging Mail Disabled
* Description: Completely blocks outgoing mail from the staging environment at the pre_wp_mail layer.
* Belt-and-suspenders against accidentally emailing real production customers.
*/
if ( ! defined( 'ABSPATH' ) ) { exit; }
add_filter( 'pre_wp_mail', function ( $null, $atts ) {
// do nothing on the production domain (double check to prevent false positives)
$home = home_url();
if ( false === strpos( $home, 'stg-yourdomain.kinsta.cloud' ) ) {
return $null;
}
// log to the error log for auditing
$to = isset( $atts['to'] )
? ( is_array( $atts['to'] ) ? implode( ',', $atts['to'] ) : (string) $atts['to'] )
: '(no recipient)';
$subject = isset( $atts['subject'] ) ? (string) $atts['subject'] : '';
error_log( sprintf( '[STAGING MAIL BLOCKED] to=%s subject=%s', $to, $subject ) );
// tell the wp_mail() caller it "sent successfully" so the normal flow continues
return true;
}, 10, 2 );There are three design points.
- A
home_url()double guard ── insurance so that if the file is accidentally copied to production, it won't stop email on production. It only short-circuits when a staging-specific hostname pattern is present. - Returning
true── by telling thewp_mail()caller the message "sent successfully," we keep plugins that retry on send failure, like UAP, from falling into a resend loop. - An audit log via
error_log()── records "what was short-circuited" in the operational logs, so any unexpected send trigger can be traced after the fact.
6-3. A placement that survives resyncs, and a master copy in the repository
The mu-plugin lives at wp-content/mu-plugins/staging-mail-disabled.php. Anything under mu-plugins/ is auto-loaded by WP without code changes and cannot be deactivated from the admin (which structurally rules out "forgot to disable it" and "mis-click").
However, if Kinsta's staging → production Files Push pushes this file to production as is, you get the opposite incident: production email stops. To avoid that, we pinned a step into the production deployment flow in CLAUDE.md: in Phase 6 (production deploy), staging-mail-disabled.php is moved out of the way (renamed so it doesn't load) right before the push and restored on staging afterward.
So the mu-plugin can be redeployed immediately if a resync wipes it, we keep scripts/staging-mail-disabled.php in the repository as the master copy. If CLAUDE.md states that "the procedure for rebuilding staging = the procedure for redeploying the mu-plugins from the repository," Claude Code will automatically bring things back to the correct state the next time it rebuilds staging.
7. Where to draw the line when an AI works over SSH
This approach only works once you draw clear boundaries. Here are three points on what we leave to the AI and where a human stops it.
7-1. All work happens on staging
Because Kinsta Staging snapshots guarantee we can roll back, all testing, trial and error, and destructive changes are done entirely on staging. Production is "the place where verified changes get deployed" and is never a target of development work. The principle this section is meant to convey is: never set things up so that an AI can touch an environment you can't roll back.
7-2. Production deploys require a human's final approval
Production deploys use Kinsta's Files-only Push (a feature that pushes only files to production without syncing the DB), run by a human from the MyKinsta dashboard. We never gave Claude Code the MyKinsta credentials, so the AI structurally cannot reach production on its own. Claude Code gets as far as reporting "it's ready to deploy," and a human is the one who finally clicks the button.
7-3. Delete API keys in advance
Since Kinsta Staging starts from a copy of production, on this project we manually deleted the SMTP API key in advance. API keys for SMTP, payment, and third-party integration plugins can remain set to production values, so we treat this as a security precaution.
8. Applying this approach to other projects
Finally, here is what can be reused and what depends on the project when you bring this approach to other WordPress projects.
8-1. What you can reuse (carries over to other projects as is)
The following is a pattern you can port almost unchanged, regardless of the project:
- The CLAUDE.md section template (project assumptions / fixed flows / prohibitions / email tone / emergency procedures)
- The Phase + checkbox design of
docs/tasks/<task>.md - The production deployment flow (Files-only Push + final human approval, documented in CLAUDE.md)
- Boundary design (staging only, secrets deleted in advance)
- Measurement style (Query Monitor + explicitly stated test conditions + a Variant A/B comparison table)
- The placement pattern for mu-plugins handling performance fixes and layered staging email defense (
wp-content/mu-plugins/+ master copies managed in the repository)
Together, these form "a pattern for bringing Claude Code into WordPress maintenance" that works regardless of the project's specifics.
8-2. What depends on the project
Conversely, the following is specific to each project and has to be rebuilt every time:
- The child theme diffs (which files are overridden and why)
- The particulars of paid plugins (UAP / WPML / Booster Plus here; a different combination on another project)
- Site-specific business rules (email tone, notification timing, payment provider specs, display requirements)
- Performance bottlenecks (UAP's category counts here; some other heavy process on another project)
- Caching strategy (this project disables the page cache on product-related pages to show dynamic auction status accurately, but typical stores and content sites normally make full use of the page cache)
These start with project-specific investigation in Phase 0 (prep), written down in CLAUDE.md / docs/tasks/.
8-3. Communication design that keeps the client involved
Beyond the technical pattern, building consensus with the client is also reproducible. We lean toward sharing the staging URL with the client and having them verify results ("The next Phase is done, please take a look here"). How we communicate AI usage is "after the results are in," with testable deliverables arriving incrementally at each Phase.
From the client's perspective, it becomes a process where "testable deliverables arrive step by step," which makes it easier to earn trust through results than by explaining AI usage in the abstract.
Closing thoughts
This article documented how we took a WordPress project where we had been cautious about major upgrades, switched it to a workflow where Claude Code runs WP-CLI over SSH, and moved WooCommerce from 4.5.5 to 10.0.4 (6 major versions) step by step. It is not a story about Claude Code on its own, but about an operational design that combines the Kinsta Staging snapshot safety net + a division of labor that keeps Phase state in CLAUDE.md / docs/tasks/ + a boundary where a human gives the final approval. We hope it helps anyone facing the same kind of problem.
MOOBON helps with untangling WordPress / Kinsta maintenance projects, upgrading long-running sites, and designing AI collaboration workflows including Claude Code. Feel free to reach out at info@moobon.jp.
Frequently Asked Questions
QIs it safe to let Claude Code SSH directly into production?
In this project we never gave Claude Code the production connection details at all. Claude Code only SSHes into Kinsta Staging; deploying to production is done by a human who manually runs a Files-only Push from the MyKinsta dashboard. As a general rule, whether you let an AI SSH directly into production comes down to whether that environment is built on the assumption that you can roll back. If you can restore it instantly from a snapshot, it may be worth considering, but the approach in this article does not recommend letting an AI touch an environment you cannot roll back.
QWhat should go into CLAUDE.md?
Project assumptions (WP / plugin versions, Kinsta setup, PHP version), the fixed production deployment flow (Files-only Push, final human approval), prohibitions (no direct SSH to production, no direct edits to wp-config.php, etc.), email tone, and emergency procedures. On this project, CLAUDE.md pins down everything from "access to Kinsta Staging only" and the procedure for moving the mu-plugin out of the way before a production deploy, to the tone used in client emails. Claude Code reads it at the start of every session, so there is no need to rebuild context each time.
QCan I bring this setup into an existing WordPress project right away?
If you have a hosting platform with SSH and WP-CLI (Kinsta, ConoHa Wing, your own EC2, etc.), all you need to do is set up CLAUDE.md and docs/tasks/. The template structure we used here carries over to other projects almost unchanged. The biggest hurdle is establishing the "we can break it and still roll back" premise (Kinsta Staging snapshots, in this case). If your environment has nothing equivalent to snapshots, the realistic first step is to put backup and restore procedures in place.
QDid the upgrade across 6 major versions really run entirely through Claude Code? Which parts did a human handle by hand?
Claude Code executed most of it via SSH + WP-CLI, but a human made the call and did the work on the following: (1) deciding which custom code to keep or drop when porting the diffs of the 7 child theme files, (2) final approval of the StoreApi fatal error investigation and the fix command, (3) mu-plugin design decisions (such as accepting the one remaining Booster Plus warning), and (4) the MyKinsta dashboard operation for the production deploy (clicking the Files-only Push button). Making explicit where the AI cannot finish the job on its own is the subject of Section 7 of this article.
QDo I need to tell the client that I am using AI (Claude Code)?
On this project we leaned toward sharing the staging URL and having the client verify the results. Rather than explaining "we used AI" in the abstract, we earned trust through a results-driven process where a testable staging site appears at every Phase. If a client explicitly asks about AI usage, we explain it candidly, but in our experience things go more smoothly when "whether AI should be used" is not put on the agenda from the start. Every client has a different tolerance for AI usage, so we reach agreement case by case.
