How to Audit WordPress Plugins Before a Server Migration
Archaeology of a 41-Gigabyte Site: orphaned integrations, database bloat, 88 plugins, and a shared server running on fumes
The card testing attack was over. Over the previous engagement, I’d analyzed 1,873 fraudulent donation attempts against the client’s payment gateway, hardened their Authorize.net settings, deployed Cloudflare WAF rules, and built a new Turnstile-protected donation form. The automated fraud stopped. The engagement was closed.
But during that initial security scan, before I’d even started the card testing remediation, I’d flagged something else: the site’s core components were dangerously outdated. The Avada theme was six minor versions behind, with thirteen known vulnerabilities including unauthenticated file upload; Fusion Builder had eight documented exploits; WooCommerce had six, including SQL injection; PixelYourSite had nine, including PHP object injection.
These weren’t theoretical risks…each had a CVE number, a public identifier in a global vulnerability database that security researchers and attackers alike use to find exploitable software. Free tools exist that scan websites, detect what software and version they’re running, and cross-reference against CVE databases automatically. The card testing bot that had hit their donation form was unsophisticated, (likely a single person running scripts), but the kind of attacker who exploits CVEs is often more methodical, and increasingly aided by AI tools that automate scanning, exploit generation, and attack execution at scale.
I recommended the client have their site developer or theme vendor handle the updates, knowing the theme configuration would need someone familiar with it to troubleshoot compatibility issues. Instead, they came back to me: could I audit the plugins, clean things up, get the site into a defensible state? The budget was $175. It seemed manageable.
What I found over the next several sessions was anything but. Beneath the surface of this nonprofit ministry’s website lay a tangle of abandoned integrations, orphaned database tables, deprecated plugin stacks, and a single bug that had been silently inflating the database for over three years. The site was 41 gigabytes. The database alone was 650 megabytes. The shared server it lived on was 95% full.
This is the story of what I found underneath.
Part One: The WooCommerce Question
Pulling the Thread
The first order of business was the most obvious one. The site had a ten-plugin WooCommerce stack—core, Stripe gateway, Authorize.net gateway, PayPal Pro, USPS shipping, custom product add-ons, donations on cart, name your price, legacy REST API, and FedEx shipping—but the client had switched to GiveWP for donation processing long ago. WooCommerce appeared to be dead weight, so the question was whether anything still depended on it.
In WordPress, you can’t just look at whether a plugin is active. Content created by that plugin (shortcodes embedded in pages, theme elements tied to its output) can persist long after the plugin stops being the primary tool. Deactivating WooCommerce without checking for these dependencies could leave pages displaying raw shortcode text instead of rendered content, or worse, trigger fatal errors from theme code expecting WooCommerce functions to exist.
I queried every published and draft post in the database for WooCommerce shortcodes: product listings, cart references, checkout elements, order tracking widgets, etc. Four pages came back: Cart, Checkout, My Account, and something called “One Page 3.” The first three were standard WooCommerce scaffolding (pages that WooCommerce creates automatically during installation and that serve no purpose once you stop using it) but Page 734 — “One Page 3” — was a mystery.
The Ghost of a Theme Demo
I pulled the content of the page and found something I see more often than you’d expect: leftover theme demo content. The page was filled with lorem ipsum placeholder text, links pointing to a theme developer’s demo domain, and branding for something called “CharityPlus.” Buried in the middle was a live WooCommerce shortcode referencing product IDs that almost certainly didn’t exist anymore.
This page had been sitting in the database since the site was first built, probably imported as part of a theme’s one-click demo setup, and never cleaned up. Nobody had ever linked to it, nobody knew it existed; it was just occupying space.
A second query targeting the Avada theme’s Fusion Builder elements returned 19 additional matches. These looked more concerning at first glance…trip info pages, newsletters, conference announcements…but a spot-check quickly revealed they were all false positives due to the word “wooded” in a trip description; they were Fusion Builder CSS class names that happened to contain the string woo_. Not a single actual WooCommerce dependency among them though.
A final check confirmed no published content anywhere on the site linked to the Cart, Checkout, or My Account pages. The WooCommerce stack was completely orphaned. All four pages went on the deletion list, and the path was clear for removal.
Technical Note: This kind of dependency audit is critical before removing any major plugin, but especially WooCommerce. Because WooCommerce registers its own post types, taxonomies, and REST API endpoints, its tentacles can reach deep into a WordPress installation. A theme like Avada compounds this by adding its own WooCommerce-specific builder elements. You can’t rely on the WordPress admin interface to tell you what’s still connected — you have to query the database directly.
Part Two: The 395-Megabyte Surprise
When a Routine Check Reveals Something Bigger
While assessing the site’s overall footprint, I ran a standard health check on the wp_options table—the WordPress table that stores site configuration, plugin settings, and cached data. In a healthy WordPress installation, this table is typically under 5 megabytes.
This one was 488 megabytes.
More alarming: 411 megabytes of that was autoloaded, meaning WordPress was reading it into memory on every single page request. Every time a visitor loaded any page, the server was hauling nearly half a gigabyte of data out of the database before it could even start building the HTML.
I pulled the largest entries to see what was consuming all that space. Three culprits appeared: a 5.7-megabyte blob from W3 Total Cache (a caching plugin that was no longer installed), a 3.4-megabyte notification cache from ExactMetrics (a Google Analytics dashboard plugin), and thousands of entries from the GiveWP Zapier integration each around 600 kilobytes.
The Zapier entries were the big ones. I counted 5,359 of them. Combined, they consumed 395 megabytes. A single integration issue was responsible for 81% of the entire database bloat.
The Investigation
My first instinct was to treat these as orphaned data and delete them, but the client confirmed they actively used a GiveWP-to-Zapier integration to sync donations with their Zoho CRM and accounting system. Before I could touch this data, I needed to understand whether it was part of the working integration or something else entirely.
I pulled a sample entry and examined its contents. Each record was a serialized PHP object containing a donation status transition (pending to publish, or abandoned to pending) along with basic post metadata. The dates told the first part of the story: the oldest entries referenced donations from October 2022…over three years old.
The Action Scheduler, WordPress’s background job processing system, which Zapier uses as its queue, was completely empty. Nothing was waiting to be sent, nothing was being retried.
The newest entry corresponded to a donation from January 30, 2026, just a few days prior, but donations made after that date weren’t creating new entries. Whatever was generating these records had recently stopped.
The Root Cause: A Cascading Batch Failure
I asked the client to check their Zapier dashboard. The screenshot they sent back contained the critical clue: the active Zap was named “Give to ZohoBooks v2.” Version two.
That “v2” meant a “v1” had existed before it, and the 5,359 stuck entries were the wreckage of that original integration.
Deeper analysis revealed the failure mechanism. When I examined the serialized data in the oldest entries, the array sizes told the story: each entry was larger than the last because it contained all the previously failed entries plus the new one.
Here’s what had happened: sometime around October 2022, the v1 Zapier webhook broke—the Zap was likely disabled, the URL expired, or authentication lapsed. The GiveWP Zapier add-on attempted to send a donation notification, failed, and queued the attempt for retry in the options table. When the next donation came in, the plugin tried to send a batch containing both the new event and the failed one. That failed too, and got queued as a two-event batch. The next attempt included three events…then four…then five.
This cascading pattern continued for over three years. Each new donation added another batch entry to the pile, and each batch duplicated every previous failure. The original donation from October 2022 appeared in every single one of the 5,359 entries. The data wasn’t 5,359 individual donation records; it was 5,359 increasingly bloated retry attempts, snowballing in size, never succeeding, never giving up.
At some point, someone set up the v2 integration using a different mechanism. The new Zap worked correctly: the client’s Zapier dashboard showed successful runs as recently as that morning, and Zoho Books confirmed recent donations were syncing properly, but nobody had ever cleaned up the wreckage from v1.
The Cleanup
Before deleting anything, I exported a list of affected donation IDs so the client would have a cross-reference if they ever noticed gaps in their Zoho records. Then I ran the deletion and optimized the table.
The wp_options table dropped from 488 megabytes to 33 megabytes. A 93% reduction. Every page on the site would now load measurably faster, because the server was no longer hauling 411 megabytes of orphaned queue data into memory on every request.
Technical Note: The wp_options table is WordPress’s most performance-critical table. Any row marked for autoloading gets read into memory on every page load — before plugins initialize, before the theme renders, before anything visible happens. A bloated options table is one of the most common causes of slow WordPress sites, and it’s almost never visible from the admin dashboard. You have to look at the database directly to find it.
Part Three: Into the Staging Environment
Setting Up the Operating Room
With the database cleaned and the WooCommerce dependencies mapped, it was time to execute the actual plugin removal. But on a site that processes real donations and syncs with a CRM, you don’t make changes on production without testing them first.
The client’s hosting contact spun up a staging environment—a clone of the production site where changes could be tested without risk. Even this step revealed problems: the staging clone came with a full copy of the site’s UpdraftPlus backup files, consuming 19 gigabytes of disk space on a 50-gigabyte server. The staging environment was born at 100% disk capacity. I had to delete the redundant backup files before I could do anything else.
The first command I ran on staging produced a wall of PHP warnings: Visual Form Builder, a legacy form plugin, declared a magic method as private — a PHP anti-deserialization security pattern from an older era. PHP 8, which the staging server ran, requires all magic methods to be public. Every single CLI command triggered this warning, cluttering the output and making it difficult to spot real errors.
The standard fix (passing a skip-plugins flag as an environment variable) didn’t work. The hosting provider’s CLI wrapper script was resetting environment variables. I worked around it by creating a configuration file in the site root, which the wrapper couldn’t override.
Small problems like these are the texture of real-world WordPress work. They don’t make the highlight reel, but they consume time and require troubleshooting before the actual work can begin.
Plugin Slug Roulette
One of the quieter surprises was that the actual plugin directory names on this installation didn’t match the standard slugs you’d find in the WordPress plugin repository. The WooCommerce Donations plugin wasn’t at its expected path; Name Your Price had a different slug than the standard one; two plugins had _bak suffixes (suggesting someone had manually renamed directories at some point rather than using WordPress’s deactivation mechanism).
This matters because command-line tools operate on directory slugs, not display names. Running a delete command with the standard slug would return “plugin not found” even though it was clearly installed. Every plugin had to be verified against the actual filesystem before any removal commands were executed.
The WooCommerce Removal
The removal itself followed a strict dependency-safe sequence: Stripe gateway first (as a canary — if the site broke, it would be immediately obvious), then the seven remaining extensions, then WooCommerce core, then file deletion. After each step, I verified the site still loaded.
The deletion went cleanly. Post-deletion, WordPress’s plugin cache briefly threw warnings for the now-missing plugin files…expected behavior that cleared with a cache flush. Then came the database cleanup: 191 WooCommerce configuration entries, 30 user metadata entries, and 32 custom database tables (everything from order statistics to session data)…all removed.
The Stuck Migrations
GiveWP, the donation plugin that was still in active use, had its own issues. Three database migrations had been showing as “incomplete” in the WordPress admin for months, with yellow banners urging the administrator to click “Resume update.” Clicking the button did nothing; the page reloaded and the banners reappeared.
I traced the problem through GiveWP’s internals. The migration runner’s code binding didn’t exist in the current version of GiveWP—the admin UI was calling a function that no longer existed. The button was cosmetically present but technically nonfunctional.
Rather than fight the broken migration runner, I examined what the migrations were supposed to accomplish. They were campaign-tracking features introduced in GiveWP 4.0: associating donations with campaigns, backfilling missing campaign IDs, and caching campaign statistics. A database query showed the work was already 99.1% complete — 11,151 of 11,251 donations had campaign IDs. The remaining 100 were from October 2022, predating the campaign system entirely, so there was nothing left to migrate.
I manually marked the three migrations as complete in the database, and the yellow banners disappeared.
Part Four: Production Day
Staging-Validated, Production-Verified
With every change tested and validated on staging, I created a focused production runbook—a step-by-step document with every command, expected output, and verification checkpoint adapted for the production environment. The runbook included discovery queries before every destructive operation, because staging and production can drift: different plugin versions, different post IDs, different amounts of accumulated data.
Production went smoothly. The numbers matched staging almost exactly: 191 WooCommerce options (identical), 30 usermeta entries (identical), 32 custom tables (identical). Minor variances were expected and documented — production had one extra orphaned optimizer record, and one fewer cache entry.
The error log told the story of a clean operation: zero WooCommerce references after removal and not a single orphaned hook, missing class, or broken shortcode. The pre-existing errors (a misconfigured Stripe add-on spamming warnings every six seconds, intermittent Redis connection drops) were documented and left for the infrastructure conversation that was already taking shape.
Part Five: The Full Reckoning
69 Plugins, and What They Revealed
With WooCommerce removed and the database cleaned, I turned to the full plugin inventory. What I found was a familiar but sobering picture: despite deleting the plugins mentioned above, the original 88 had only dropped to 69 — 47 active and 22 inactive.
The archaeology told a story that most long-running WordPress sites share. At some point, someone had installed Gravity Forms for form handling. Later, someone else installed WPForms. Both remained, alongside their respective add-ons and Zoho connectors. There were three different popup plugins — all inactive, all still sitting on the server with their database tables intact; two email marketing integrations coexisted alongside a Zapier-based flow to Zoho CRM; an inactive Jetpack installation; an inactive security scanner; one image optimizer sitting alongside another, its replacement; a slider plugin that someone had deactivated but never removed.
Each of these plugins, even deactivated, represented files on the server…their PHP code could still be reachable by direct URL request. Attackers don’t need a plugin to be active to exploit it; they just need the vulnerable file to exist at a predictable path. The inactive slider plugin alone had a history of critical vulnerabilities that had been used in mass WordPress compromises.
Then there was the active file manager plugin: a tool that provides browser-based filesystem access through the WordPress admin. File manager plugins are among the most frequently exploited attack vectors in the WordPress ecosystem, with a history of critical remote code execution vulnerabilities. Its presence on a production site processing financial transactions was a significant security concern.
The Cleanup
Working from the inventory, I organized the removal into batches by risk level:
Batch 1 — Eleven plugins that were unambiguously safe to delete: Hello Dolly, a redundant lite version of the active form builder, a redundant caching plugin, one-time migration and import utilities, and other clearly orphaned tools.
Batch 2 — Sixteen plugins confirmed by the client as no longer needed: the entire legacy form builder stack, duplicate email marketing plugins, inactive popup plugins, the heavyweight Jetpack, an inactive security scanner, the slider plugin, and a custom ministry tool from a previous initiative.
Batch 3 — Two active plugins removed for cause. The GiveWP Stripe add-on was spamming PHP error warnings every six seconds because Stripe API keys were never configured (the site used Authorize.net and PayPal Commerce, not Stripe). The error log went clean the moment it was removed. The file manager plugin was removed after confirming the client had SSH and SFTP access as alternatives.
After the plugin removals came the orphaned database tables—the debris left behind by previously deleted plugins. WordPress doesn’t clean up after itself when plugins are removed. The tables just sit there, taking up space, adding overhead to database operations, and occasionally causing confusion during audits.
I identified and dropped 82 orphaned tables across eleven former plugins…form builders, popup tools, email marketing platforms, lead trackers, registration systems. The database went from 187 tables to 105.
Plugin Updates
With the dead weight removed, I turned to the living plugins. Sixteen had pending updates, ranging from minor patches to major version jumps. These were executed in three rounds of increasing risk:
Low-risk patches (six plugins, batched): Minor version bumps for utility plugins — menu styling, meta boxes, analytics counters, social icons.
Medium-priority updates (five plugins, individually): Larger jumps including a core theme dependency framework, the Zoho CRM integration, and two plugins crossing major version boundaries. Each was updated independently with a site check between.
High-priority updates (five plugins, individually with validation): The email delivery plugin (confirmed with a test email), the caching plugin (failed — license expired), the object cache, the SEO plugin (sitemap verified), and the image optimizer.
The image optimizer’s update (a major version jump) threw fatal PHP errors immediately after installation. The autoloader from the old version was caching class paths that no longer existed in the new directory structure. A deactivate-reinstall-reactivate cycle resolved it cleanly, with all existing settings preserved.
Two premium plugins couldn’t be updated due to expired licenses. A third, the form builder, had an expired license affecting both the core plugin and its ten active add-ons, leaving 30+ forms without security patches.
The Form Builder Decision
The client initially believed they weren’t using their form builder heavily and considered letting the license lapse. A database audit told a different story: 32 form definitions, 10 actively embedded on published pages — trip interest forms, mission trip applications, corporate giving requests, RSVP forms, internship applications, and permission/release forms.
I extracted and cataloged every field type across all forms to determine which ones required paid features. The analysis found signature capture fields, payment processing fields, and file upload fields, all requiring the paid tier. But these were concentrated in just two of the ten active forms: a minor mission trip application (signatures plus file uploads) and a permission and release form (signatures only). The other eight active forms used only basic field types.
The client had three real options: renew the license for roughly $200 per year (lowest risk, all forms keep working), downgrade to the free tier and accept that two forms would break, or migrate to another plugin entirely (the most expensive option in labor, with no credible free alternative for legally binding signature capture). I laid out the trade-offs and let them decide.
Part Six: The Avada Update
Six Minor Versions in One Jump
The site’s theme — Avada, one of the most complex WordPress themes in existence — was at version 7.8.2. The current release was 7.14.2…six minor versions behind, spanning months of page builder changes, PHP compatibility updates, and security patches. These were among the vulnerabilities I’d flagged after the card testing engagement, the ones that had prompted this entire audit.
Theme updates on Avada carry more risk than most. Avada isn’t really a theme insofar as it’s a page builder ecosystem. Every page on the site is constructed with Fusion Builder shortcodes that are interpreted by the theme. A breaking change in how those shortcodes render could affect every page simultaneously.
I took a database export as a rollback point before proceeding. On staging, the update required three attempts — the CLI failed twice due to Avada’s licensing system (a staging domain registration conflict, then an opaque API error) before succeeding through the WordPress admin interface. On production, the CLI worked on the first try because the license token was already registered.
Post-update validation on both environments was clean: homepage, trip info pages, donation forms, contact forms, the admin dashboard, and the theme settings panel all rendered correctly. Error logs showed zero new entries from the theme update.
Twelve inactive themes were also removed—default WordPress themes accumulated over years of core updates and leftover theme candidates from the site’s original setup.
Part Seven: The Infrastructure Picture
What the Audit Revealed About the Server
The plugin audit was scoped as application-level work, but it inevitably painted a picture of the infrastructure underneath, and that picture had problems.
The site was running PHP 7.4, which reached end of life in November 2022. No security patches for over three years. Known vulnerabilities publicly documented and actively exploited. The database engine was approaching its own end-of-life. The Redis object cache was configured at 2 gigabytes and running at full capacity, causing constant evictions and degraded performance. The backup plugin hadn’t successfully completed a backup since May 2023, and when it did run, it stored backups locally on the same server, consuming 19 to 29 gigabytes of disk space with no offsite copy.
The server itself was shared with 34 other websites, memory utilization fluctuated between 46% and 80%, and the disk was at 95% capacity.
None of these were things a plugin audit could fix but they were impossible to ignore.
A Conversation About Hosting
Midway through the engagement, the client’s hosting contact — who had been coordinating access and spinning up the staging environment — asked me if I’d be interested in taking over the hosting relationship. He mentioned it to the client, suggested I follow up. The client was open to it.
It made sense. I’d spent weeks inside this site’s internals; I knew its dependencies, its integration points, its failure modes, its actual resource requirements; I knew the PHP version needed to go from 7.4 to 8.2; I knew the database engine needed upgrading; I knew Redis needed to be right-sized; I knew the backup system was broken and had been for almost three years; I knew the shared server was running out of room.
More importantly, I knew what the site actually needed — not what a generic hosting spec sheet would recommend, but what this specific site with its GiveWP donation processing, its Zapier-to-Zoho CRM sync, its Authorize.net and PayPal Commerce payment gateways, its 30+ embedded forms, and its Avada page builder required to run reliably.
I put together a proposal: a dedicated server on modern infrastructure, properly sized for the site’s actual workload, with the OS, database, PHP, and caching stack all brought current, and adding daily automated offsite backups, server-level security hardening, all making for a clean migration with the compatibility testing and integration verification that a site this complex demands.
The plugin audit had become the pre-migration assessment for a complete infrastructure transition.
Lessons for Site Owners
Plugin accumulation is a compounding problem. Every plugin installed and forgotten is code on your server that attackers can reach, database tables that slow queries, and a dependency that complicates future maintenance. This site had 88 plugins — including three popup tools, two form builders, two image optimizers, and two email marketing integrations. Periodic audits shouldn’t be viewed as optional but rather as required maintenance.
Your database is telling you things you can’t see from the dashboard. The 395 megabytes of orphaned Zapier queue data had been silently degrading every page load for three years. No WordPress admin screen shows you this, no plugin alerts you to it. The only way to find it is to look at the database directly, and most site owners never do (most don’t even know how).
Deactivated plugins aren’t harmless. The 27 inactive plugins on this site included tools with histories of critical remote code execution vulnerabilities. Deactivation stops a plugin from running code through WordPress’s normal execution flow, but the PHP files remain on the server at predictable, web-accessible paths. If a vulnerability allows direct file access (and many do) the plugin doesn’t need to be active to be exploited.
Staging environments are non-negotiable for sites that handle money. This site processes donations and syncs with a CRM and accounting system. Every change was tested on staging first, validated, and then replicated on production with verification at each step. The image optimizer crash that surfaced during updates? It happened on staging, where it caused zero downtime. On production, the fix was already known.
Pre-migration audits save migrations. Moving a 41-gigabyte WordPress site with 88 plugins, a 650-megabyte database, and dependencies on multiple third-party services is fundamentally different from moving a clean, well-maintained site. The audit identified which plugins could be removed before migration (reducing complexity), which database issues needed resolution (reducing size and migration time), which PHP compatibility problems would surface during the upgrade (eliminating surprises), and which integrations needed post-migration verification (defining the test plan). Without this work, the migration would have been a high-risk operation conducted in the dark.
Conclusion
A plugin audit is, at its core, an act of understanding. You’re building a complete picture of what a WordPress site actually is…not what it appears to be from the admin dashboard, but what’s really running, what’s really stored, and what’s really connected. The dashboard shows you 88 plugin cards but the database shows you 395 megabytes of orphaned queue data from a broken integration that ran for three years, and the filesystem shows you theme demo content from the day the site was built, still sitting in a page that nobody has ever visited.
This nonprofit ministry’s website was functional: donations were processing, the CRM was syncing, pages were rendering, and from the outside, everything looked fine. But underneath, the accumulation of years of quick fixes, abandoned experiments, and deferred maintenance had created a site that was measurably slower, larger, and more vulnerable than it needed to be.
The plugin audit became something more: a comprehensive pre-migration assessment that documented every moving part, dependency, and buried problem. That understanding — not just of what to move, but of what to leave behind, what to fix in transit, and what to verify on arrival — is what makes the difference between a migration that goes smoothly and one that doesn’t.
The migration itself is a story for Part 2.
This case study describes real work performed by Stonegate Web Security. Client details have been anonymized and certain identifying specifics altered. Technical details, methodologies, and findings are reported accurately.
Related Reading
-
Case Study: The Evening Hobbyist & His 1,873 Fraudulent Donations
The engagement that started it all — the card testing attack that first revealed this site's outdated infrastructure. -
Case Study: Migrating a WordPress Site from Shared Hosting to a Self-Managed VPS
A nonprofit's WordPress site moves from a shared server with 34 other sites to a dedicated VPS. -
Outdated Plugins — The Hidden Risk to Your Website’s Security
Ignoring one plugin update is like leaving a window unlocked. Learn how outdated plugins expose your site to hackers — and how to keep every lock secure.