Why most plugins rot
Open enough WordPress plugins and you start noticing the same shape. A single 2,000-line file in the root of the plugin folder. Globals everywhere. A class named after the plugin that holds every piece of functionality. add_action calls scattered through that class so you cannot tell which feature triggers what without grepping. A $wpdb->query three levels deep inside an admin_notices handler. Custom database tables created on activation but never migrated when the schema changes. Strings hardcoded in English because internationalisation was a "phase two" task that never came.
None of those problems are caused by WordPress. They are caused by the plugin growing one feature at a time, with no architectural decisions made up front. The first version is "small enough" to be careless with. By the time it isn't small enough, the carelessness is locked in.
The fix is not to over-engineer the first release. The fix is to make a few cheap structural decisions on day one that pay off for years. None of what follows is exotic. All of it is what I wish more plugins did before they shipped.
A folder structure that ages well
Here is the layout I use for any plugin I expect to ship more than two releases of:
my-plugin/
├── my-plugin.php # Plugin header + bootstrap, ~30 lines max
├── uninstall.php # Deletion contract — runs when the user clicks Delete
├── readme.txt # WordPress.org readme
├── composer.json # PSR-4 autoload, dev dependencies
├── src/
│ ├── Plugin.php # The single class that wires everything together
│ ├── Admin/ # Admin pages, settings, list tables
│ ├── Frontend/ # Public-facing rendering, shortcodes, blocks
│ ├── Rest/ # REST API controllers
│ ├── Cli/ # WP-CLI commands (optional but cheap to add)
│ ├── Database/ # Schema + repository classes
│ ├── Services/ # Pure-PHP business logic, framework-free
│ └── Support/ # Helpers, value objects
├── assets/
│ ├── css/
│ ├── js/
│ └── images/
├── languages/ # .pot file lives here
├── templates/ # Override-able front-end templates
└── tests/
├── Unit/ # Pure PHPUnit, no WordPress required
└── Integration/ # WordPress test suite
The key idea is the split between src/Services/ and everything else. Services contains plain PHP classes that know nothing about WordPress. They take inputs, return outputs, throw exceptions on failure. They are unit-testable in milliseconds. The rest of src/ is the layer that hooks the WordPress lifecycle into those services — Admin pages call services, REST controllers call services, CLI commands call services. WordPress becomes a delivery mechanism, not the centre of your code.
This is what makes the plugin survive a feature pivot. When a client asks "can we expose this through the REST API instead of the shortcode?" the answer is yes, in an afternoon, because the actual logic doesn't change.
PSR-4 autoloading inside a WordPress plugin
You do not need a build step to use Composer's autoloader. You only need composer install once and then ship the resulting vendor/autoload.php with the plugin (or generate it during your release pipeline). Your composer.json looks like:
{
"name": "your-vendor/my-plugin",
"type": "wordpress-plugin",
"require": { "php": ">=7.4" },
"autoload": {
"psr-4": { "MyVendor\\MyPlugin\\": "src/" }
}
}
Now classes load themselves. No manual require_once chains, no class-name-to-file naming conventions invented from scratch. New file in src/Admin/SettingsPage.php? It exists as MyVendor\MyPlugin\Admin\SettingsPage with no further wiring. That sounds small but it is the difference between adding a new feature in 30 minutes and adding it in two hours of "why isn't this loading".
If you want to support hosts that strip vendor/, ship a tiny fallback autoloader in your bootstrap. But almost nobody does that any more — even WordPress.org accepts vendored Composer dependencies.
One bootstrap file, no surprises
The plugin's main file should do four things and stop:
- Declare the plugin header.
- Refuse to run if accessed directly.
- Define a couple of constants (path, URL, version).
- Boot the
Pluginclass on the right hook.
<?php
/**
* Plugin Name: My Plugin
* Version: 1.0.0
* Requires PHP: 7.4
* Text Domain: my-plugin
*/
defined( 'ABSPATH' ) || exit;
define( 'MYPLUGIN_FILE', __FILE__ );
define( 'MYPLUGIN_PATH', plugin_dir_path( __FILE__ ) );
define( 'MYPLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'MYPLUGIN_VER', '1.0.0' );
require_once MYPLUGIN_PATH . 'vendor/autoload.php';
add_action( 'plugins_loaded', static function () {
\MyVendor\MyPlugin\Plugin::instance()->boot();
} );
Do not put any feature code in this file. Every time someone adds just this one little thing here, the plugin grows a load order bug. The bootstrap is for bootstrapping; the Plugin class is where you decide what runs when.
Treat hooks as your seams, not your code
Hook handlers should be thin. They translate the WordPress event into a method call on a service, and they translate the service's return value into a WordPress-shaped response. They should not contain business logic.
// In src/Admin/SaveOrderHandler.php
public function on_save( int $post_id ): void {
if ( ! $this->should_handle( $post_id ) ) return;
$dto = OrderDtoFactory::from_post( $post_id );
$result = $this->orders->process( $dto );
if ( $result->is_error() ) {
$this->notices->flash_error( $result->message() );
}
}
The actual processing lives in Services\Orders, which is a PHPUnit-testable class with no WordPress dependencies. The hook handler does plumbing. This pattern is what lets you test 80 % of the plugin without booting WordPress at all.
Also: register every hook in one place per feature, not scattered through random methods. A small register_hooks() at the top of each feature class makes it possible to read the file and immediately see what events it cares about.
Schema, options and the version flag
If your plugin owns a database table, you owe future-you two things: a schema definition that dbDelta can run, and a stored version number for that schema.
const SCHEMA_VERSION = 3;
public function maybe_upgrade(): void {
$current = (int) get_option( 'myplugin_schema_version', 0 );
if ( $current === self::SCHEMA_VERSION ) return;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $this->schema_sql() );
if ( $current < 2 ) $this->migration_2();
if ( $current < 3 ) $this->migration_3();
update_option( 'myplugin_schema_version', self::SCHEMA_VERSION );
}
Run maybe_upgrade() on plugins_loaded, not just on activation. Activation does not fire when a user updates the plugin via the dashboard, which is exactly when most schema changes need to apply. The version flag means the migration is idempotent and cheap on every load.
For options, group them under one prefix (myplugin_*) so they are easy to clean up. Keep an inventory of every option you store. The uninstall.php file is the contract that you will delete them.
Build the settings page once, not for every release
Most "we need a settings page" requests turn into a hand-rolled HTML form, hand-rolled nonces and hand-rolled validation. Three releases later it is unmaintainable. The Settings API is verbose but it gives you a declarative model: register sections, register fields, register sanitizers. Each new field is a one-line addition to a config array. Permission checks, nonces, capability gating and persistence are handled for you.
Treat the field list as data. Loop over it to render. Loop over it again to sanitize. The day you need to expose those settings via REST or CLI as well, you have one source of truth.
Internationalisation and the uninstall contract
Two unglamorous habits that pay off forever:
- Wrap every user-visible string in
__()oresc_html__()from day one. Retrofitting i18n later is brutal — you'll miss strings, mistranslate context and never recover. Set a text domain in the plugin header, generate a.potfile, and use it. - Honour the uninstall contract. A user clicks Delete because they want this plugin gone. Drop your custom tables. Delete your options. Cancel your scheduled events. Anything you create must have a corresponding teardown in
uninstall.php. This is not optional — it is the difference between a plugin that gets recommended and one that gets blacklisted by hosts.
Testing without WordPress
The slowest part of testing a WordPress plugin is booting WordPress. The fastest part is not booting it. If your Services layer is framework-free, you can run thousands of unit tests in seconds with vanilla PHPUnit. Keep an integration test suite that boots WordPress for the layer that actually touches it — REST endpoints, hook flows, database queries — but make that suite small. The pyramid stays cheap.
This is a multiplier effect. Plugins with fast tests get refactored. Plugins with slow tests get patched.
Takeaways
- Split your plugin into a thin WordPress layer and a fat service layer that knows nothing about WordPress.
- Use Composer's PSR-4 autoloader. Every new file just works.
- Keep your bootstrap file boring. Put feature code in classes.
- Hooks are seams. Handlers translate; services decide.
- Track a schema version. Migrate on
plugins_loaded, not just activation. - Use the Settings API as data, not as code.
- Wrap strings, honour uninstall, write tests that don't boot WordPress.
None of this is exotic. All of it is the difference between a plugin you ship once and a plugin you can still maintain in five years.
Building a plugin and want a senior pair of eyes on the architecture before you commit? Send me a brief — I do plugin reviews and rescue work as one-off engagements, written report included.