One of our older plugins had grown into a single 800-line file of loosely related functions, all hooked into WordPress actions and filters, and adding a new feature meant scrolling through the whole thing to figure out where it belonged, and usually finding at least one function whose name no longer described what it actually did after a year of small patches.
What the old structure actually looked like
Everything lived in one file, includes/plugin-functions.php, functions named things like handle_settings_save and maybe_output_widget sitting next to each other with no organizing principle beyond "we needed this at some point." Global variables carried state between functions that had no formal relationship to each other beyond both being hooked into the same WordPress action at slightly different priorities. It worked, in the sense that the plugin ran without errors, but understanding it required basically holding the whole file in your head at once.
The rewrite
We rewrote it this year as a small set of classes, one handling the admin settings screen, one handling the actual front-end output, one handling any custom post type registration. Nothing exotic, no autoloader magic, just require_once calls in the main plugin file, but the separation alone made the codebase far easier to reason about.
- Plugin_Settings handles reading and writing options, and rendering the settings screen, nothing else touches the options table directly anymore.
- Plugin_Frontend hooks into the relevant template tags and shortcodes, and knows nothing about how settings are stored, it just asks Plugin_Settings for a value.
- Plugin_Post_Types owns registering the custom post type and any custom taxonomies, isolated so it can be reused wholesale in a different plugin later if we need the same pattern again.
The part that actually paid off immediately
The real win showed up faster than expected, a client asked for a small new setting, a toggle to hide a specific field on the front end, in the middle of the refactor. Under the old structure that would have meant grepping through 800 lines to find where the relevant output happened and where settings got read, then hoping we didn't miss a second place doing the same thing. Under the new structure it was obvious: add a field to Plugin_Settings, check it in the one method in Plugin_Frontend responsible for that output. Fifteen minutes, no archaeology required.
Where we drew the line on "proper" structure
We deliberately didn't reach for a dependency injection container, an autoloader, or a service locator pattern, all things we've read about in more architecture-focused corners of the PHP world. For a plugin with three classes and no plans to grow into ten, that would have been solving a problem we don't have yet at the cost of a learning curve and more code to maintain. require_once at the top of the main plugin file, in the right order, does the job completely for something this size, and we can always introduce an autoloader later if the plugin actually grows enough to justify it.
What it cost us
It took longer to rebuild than it would have taken to just add the new feature to the old file, probably three or four times longer, all told. That's a real cost, and if this client work had been under tighter deadline pressure we probably wouldn't have made the case for spending that time on structure rather than shipping the feature. But six months from now, when we've forgotten how this plugin works, future us will be glad we did it properly, and the next feature request, whatever it turns out to be, should cost a fraction of what this one would have cost against the old file.
A pattern we're now applying elsewhere
We've since gone back and given the same treatment to a second, smaller plugin, mostly to see whether the same class breakdown, settings, front-end output, post type registration, holds up as a general starting shape for this kind of WordPress plugin rather than being specific to the first one's particular feature set. So far it does, which suggests we've landed on something closer to a reusable pattern than a one-off fix for a single messy file.
Writing our first tests for a WordPress plugin
While rebuilding Plugin_Settings we finally wrote a handful of actual unit tests, something we'd never bothered with for a WordPress plugin before because so much of what a typical plugin does is tightly coupled to WordPress core functions that are awkward to test in isolation. We used WP_Mock to stub out the WordPress functions our settings class calls, get_option and update_option mainly, which let us test our own validation and sanitization logic without needing a full WordPress installation spun up just to run a test suite. It's a small number of tests, maybe a dozen, covering the settings validation logic specifically, but it's already caught one regression, a sanitization function that stopped correctly stripping HTML from a text field after an unrelated change, before it reached a client site.
Documenting the new structure
We wrote a short README inside the plugin's own folder, something we've historically skipped entirely, explaining what each of the three classes does and, more importantly, where a new feature request should probably go. It's maybe two hundred words, nothing elaborate, but it's already paid off once when we handed a small fix on this plugin to a contractor helping out during a busy stretch, and they were productive within twenty minutes of reading it rather than needing a walkthrough call to understand the codebase first.
A bug the refactor accidentally fixed
While separating Plugin_Frontend from Plugin_Settings, we noticed the old code was calling get_option twice in two different functions for the same setting, once cached in a local variable and once not, which meant the two functions could theoretically disagree about a setting's value within the same request if the option changed between the two calls, an extremely unlikely but real race condition. The new structure, with a single method on Plugin_Settings responsible for reading that value, made the duplicate call impossible to reintroduce by construction rather than by discipline, which is exactly the kind of bug class we're now more attentive to eliminating structurally rather than just fixing the one instance we happened to find.
How we're deciding when a plugin is worth this treatment
Not every plugin gets this treatment, and we've had to come up with a rough rule for which ones do. If a plugin is under a couple hundred lines and unlikely to grow, we leave it alone, the overhead of classes and a README genuinely isn't worth it for something that small and stable. Past that size, or for anything we expect to keep extending over multiple client engagements, the class-based structure earns its keep quickly enough that we now default to writing new plugins that way from the start rather than only retrofitting old ones after they've already become painful to work in.
Rolling this out to a client-facing changelog
One side effect we didn't anticipate: with the plugin's responsibilities cleanly separated, writing an actual changelog for client updates got noticeably easier too. Instead of a vague "bug fixes and improvements" line, we can now say something specific, "fixed a settings validation issue," "improved gallery archive performance," because the class boundaries make it obvious which part of the plugin a given fix actually touched. Clients rarely read changelogs closely, but the couple who do have commented that our update notes read as more trustworthy now, less like boilerplate and more like we actually know what changed, which we do, because the refactor forced us to know it ourselves first.
Whether we'd do the same for every legacy plugin we own
We went back through our older plugins afterward and made a short list of which ones would actually benefit from the same treatment versus which ones are small and stable enough to leave alone. The deciding factor wasn't age or line count alone, it was how often we're still asked to change a given plugin. A five-year-old plugin nobody has touched in a year isn't worth refactoring just because it's old; a two-year-old plugin we edit every other month absolutely is, because every edit under the old structure costs more than it should, and that cost compounds every time a new feature request comes in on top of the last one.