WordPress Plugin Development: OOP PHP Best Practices
A plugin structure that is still readable at fifty classes: a thin main file, namespaces, autoloading and hooks registered on purpose.

Short answer
Structure an OOP WordPress plugin around a small main file that checks requirements and boots one plugin class, PHP namespaces instead of a prefix on every function, Composer's PSR-4 autoloader instead of manual require calls, and small classes that each own one job and register their hooks in a dedicated method rather than in the constructor.
Structure an object-oriented WordPress plugin around four things: a small main file that checks requirements and boots one plugin class; PHP namespaces instead of a prefix on every function; Composer's PSR-4 autoloader instead of a list of require calls; and small classes that each own one job and register their hooks in a dedicated method, not in their constructors. That combination keeps a plugin readable at fifty classes as well as at five.
This article is for developers. If you are a business owner deciding whether you need a plugin at all, when do you need a custom WordPress plugin is the better starting point, and WordPress plugin development security best practices covers nonces, capabilities, sanitising and escaping, which I will not repeat here.
A WordPress plugin folder structure that scales
The WordPress plugin handbook's best practices page recommends keeping the main plugin file and uninstall.php at the root and everything else in subfolders. The layout I use:
- acme-bookings.php: the main file, header and bootstrap only
- uninstall.php: clean-up when the plugin is deleted
- composer.json: autoloading and development dependencies
- src: every namespaced class, in folders by area such as Admin, Frontend, Rest and Data
- templates: PHP view files, kept out of the classes
- assets: compiled CSS and JavaScript
- languages: translation files
- tests: unit and integration tests
- vendor: generated by Composer and shipped in the release build
The main file: as little as possible
The main plugin file should do five things and nothing else:
- Carry the plugin header, including Requires at least, Requires PHP and, where the plugin depends on another plugin, Requires Plugins, which the header requirements page notes arrived in WordPress 6.5.
- Exit if ABSPATH is not defined, so the file cannot be run directly.
- Load Composer's autoloader.
- Register the activation and deactivation hooks, passing __FILE__, because they have to be registered against the main plugin file.
- Boot the plugin on plugins_loaded, so every other plugin is already available.
Anything more in the main file is logic that cannot be tested in isolation.
Namespaces and autoloading
Put every class under one namespace, for example Acme\Bookings, with sub-namespaces matching the folders under src. In composer.json, map that namespace to the src folder with PSR-4 autoloading, and Composer loads each class the first time it is used. There is no require_once list to maintain, and a class name tells you where its file lives.
Two things namespaces do not solve:
- Anything WordPress stores globally still needs a prefix. Option names, transients, post meta keys, custom hook names, shortcodes and global constants share one space with every other plugin. The handbook asks for a prefix of at least four letters, ideally five, and warns against wp_ and double underscores.
- Bundled libraries can still clash. If two plugins ship different versions of the same Composer package, whichever loads first wins. For a plugin distributed to many sites, scope your dependencies with a tool such as PHP-Scoper or Strauss.
Registering hooks from classes
Do not call add_action in a constructor. A constructor with side effects cannot be created in a test without firing hooks, and it hides the moment the class starts affecting WordPress.
Instead, give each class that touches WordPress a register method that adds its hooks, and call it from the plugin class during boot, passing the object and method name as the callback.
Three habits that save debugging time:
- Name callbacks after what they do, such as add_settings_page or enqueue_booking_assets, not after the hook.
- Avoid closures for hooks other code may need to remove. remove_action needs the same callback and priority, which is impossible with an anonymous function.
- Keep the instance reachable. If another plugin or a theme may need to unhook you, expose the instance through a documented function or filter.
One class, one job
| Class | Responsibility | Hooks it registers |
|---|---|---|
| Plugin | Builds the other objects and calls their register methods | None itself; booted from the main file |
| Admin\SettingsPage | The settings screen and its fields | admin_menu, admin_init |
| Frontend\Assets | Scripts and styles, only where needed | wp_enqueue_scripts |
| Rest\BookingController | REST routes and their permission callbacks | rest_api_init |
| Data\BookingRepository | Reading and writing bookings | None |
The repository registers nothing. It is plain PHP that talks to the database or to WordPress APIs, which makes it the easiest class to test. When a controller needs it, pass it in through the constructor rather than creating it inside. That is all the dependency injection most plugins need.
On singletons: a single, explicitly created Plugin instance is fine. Making every class a singleton with a static instance method turns the plugin into global state with extra steps, and makes testing harder.
Activation, upgrades and uninstall
- Activation: create custom tables with dbDelta, set default options and store a schema version. Do not rely on activation running when the plugin is updated, because it does not.
- Upgrades: on boot, compare the stored version with the current one and run any migrations when they differ.
- Uninstall: uninstall.php should check WP_UNINSTALL_PLUGIN and remove only what the plugin created, ideally only if the site owner has chosen to delete the data.
Custom tables are often unnecessary. WordPress custom post types and custom fields covers when the built-in data model is enough.
Testing the structure
Classes with no side effects in their constructors can be unit tested with PHPUnit, using a library such as Brain Monkey to stand in for WordPress functions. Integration tests against the WordPress test suite catch the rest. Even a small suite around the repository and the settings validation catches regressions before a client does, and it pays off every time WordPress or PHP updates; how to update WordPress safely covers the site owner's side of that.
Next step
If you are commissioning a plugin rather than writing one, ask the developer how they structure it and whether it will have tests. How much custom WordPress plugin development costs explains why the answer affects the quote. If you want one built this way, the plugin development service does exactly that, and the WordPress website development guide covers where a plugin fits in a wider build.
Related services
Related reading
WordPress
WordPress Website Development: What Businesses Actually Need
The decisions that determine whether a WordPress site earns its keep are made in the first week, not the last. Here is what they are.
WordPress
When Do You Need a Custom WordPress Plugin?
Most sites never need one. Some sites are held together by six plugins doing the job one small custom plugin would do better. Here is how to tell which you have.
WordPress
WordPress Plugin Development Security Best Practices
Most plugin vulnerabilities come from the same handful of mistakes. Here is what secure plugin code does differently, and how to check a plugin you have paid for.
WordPress
How Much Does Custom WordPress Plugin Development Cost?
A small plugin is cheaper than most people fear. A system plugin is dearer than most people hope. The scope decides which one you are buying.