Building a WordPress plugin from scratch, in the sense that matters to an experienced developer, means refusing every scaffolding generator and deciding the architecture yourself. That is a defensible choice when the plugin will live for years, carry custom tables, expose an API, and be maintained by more than one person. This guide lays out the structure worth committing to: Composer autoloading, a container instead of singletons, versioned schema, REST routes, isolated unit tests, and a pipeline that runs them.
composer.json that maps a PSR-4 namespace to src/, keep the root plugin file as a thin bootstrap that requires vendor/autoload.php and instantiates one Plugin object, register services in a small container rather than calling static instances, version your schema and run dbDelta() only when the stored version differs, expose data through register_rest_route() with a real permission_callback, and test the pure logic with PHPUnit plus WP_Mock in continuous integration.If you are looking for the entry level path, read our beginner tutorial on creating a WordPress plugin instead, which covers headers, hooks, and the Settings API. For a single complete feature build with a block and a shortcode, see how to make a WordPress plugin that does something useful. This article assumes you already know all of that and want to know how to keep ten thousand lines of it maintainable. Targets below are PHP 8.1 or newer and WordPress 6.7 or newer, with the current release line being WordPress 7.1.
The layout, and what each layer owns
Directory structure is architecture made visible. The rule is that the root file knows almost nothing, src/ holds classes that never echo, and anything that touches WordPress globals is isolated behind a thin adapter so tests can replace it.
acme-ledger/
├── acme-ledger.php # header + bootstrap, ~30 lines
├── composer.json
├── phpunit.xml.dist
├── uninstall.php
├── src/
│ ├── Plugin.php
│ ├── Container.php
│ ├── Contracts/Bootable.php
│ ├── Admin/SettingsScreen.php
│ ├── Rest/EntriesController.php
│ ├── Database/Schema.php
│ └── Domain/LedgerCalculator.php
├── tests/
│ ├── bootstrap.php
│ └── Unit/LedgerCalculatorTest.php
└── .github/workflows/ci.yml| Layer | May call WordPress functions | Test strategy |
|---|---|---|
Domain/ | No | Plain PHPUnit, no mocks needed |
Database/ | Yes, $wpdb only | Integration tests against a real database |
Rest/, Admin/ | Yes | WP_Mock for hook and function expectations |
| Root plugin file | Yes, minimally | Not tested, kept trivial on purpose |
Composer and PSR-4 autoloading
WordPress core has no autoloader, so you bring your own. Composer generates a classmap and a PSR-4 resolver; you commit vendor/ for distribution because WordPress.org does not run composer install for you.
{
"name": "acme/ledger",
"type": "wordpress-plugin",
"require": { "php": ">=8.1" },
"require-dev": {
"phpunit/phpunit": "^9.6",
"10up/wp_mock": "^1.0",
"wp-coding-standards/wpcs": "^3.1"
},
"autoload": { "psr-4": { "Acme\\Ledger\\": "src/" } },
"autoload-dev": { "psr-4": { "Acme\\Ledger\\Tests\\": "tests/" } },
"config": { "optimize-autoloader": true }
}
composer install
composer dump-autoload --classmap-authoritative --no-devThe autoload-dev block keeps test classes out of the production classmap, and --classmap-authoritative tells Composer to stop hitting the filesystem for classes it already knows about, which measurably reduces stat calls on a busy site. Run the optimized dump as part of your release build rather than during development, because an authoritative classmap does not pick up new files until you regenerate it.
A bootstrap that avoids the singleton habit
The classic WordPress pattern is a class with a static instance() method calling itself from everywhere. It is convenient and it is why so many plugins are impossible to test: every consumer reaches into global state. Replace it with a container built once in the root file and passed down.
<?php
/**
* Plugin Name: Acme Ledger
* Version: 1.0.0
* Requires at least: 6.7
* Requires PHP: 8.1
* License: GPL-2.0-or-later
* Text Domain: acme-ledger
*/
declare( strict_types=1 );
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! is_readable( __DIR__ . '/vendor/autoload.php' ) ) {
return;
}
require_once __DIR__ . '/vendor/autoload.php';
( new \Acme\Ledger\Plugin( __FILE__ ) )->boot();
<?php
namespace Acme\Ledger;
use Acme\Ledger\Contracts\Bootable;
final class Plugin {
private Container $container;
public function __construct( private string $file ) {
$this->container = new Container();
}
public function boot(): void {
$this->container->set( 'file', fn() => $this->file );
$this->container->set( Database\Schema::class, fn( $c ) => new Database\Schema( $GLOBALS['wpdb'] ) );
$this->container->set( Domain\LedgerCalculator::class, fn() => new Domain\LedgerCalculator() );
$services = [
Admin\SettingsScreen::class,
Rest\EntriesController::class,
];
foreach ( $services as $class ) {
$service = $this->container->get( $class );
if ( $service instanceof Bootable ) {
$service->register_hooks();
}
}
register_activation_hook( $this->file, [ $this->container->get( Database\Schema::class ), 'install' ] );
}
}Every service declares its dependencies in its constructor and exposes a single register_hooks() method. Nothing calls add_action() at file scope, which means loading the class in a test does not attach anything to WordPress. That one constraint makes the rest of the testing story possible.
Custom tables and versioned schema
Options and post meta cover most needs. When you genuinely have relational data with millions of rows, a custom table is correct. dbDelta() is the supported way to create and alter one, and it is famously picky: two spaces after PRIMARY KEY, uppercase types, one field per line, and the key name repeated.
<?php
namespace Acme\Ledger\Database;
final class Schema {
public const VERSION = '3';
public function __construct( private \wpdb $wpdb ) {}
public function table(): string {
return $this->wpdb->prefix . 'acme_ledger_entries';
}
public function maybe_upgrade(): void {
if ( get_option( 'acme_ledger_db_version' ) === self::VERSION ) {
return;
}
$this->install();
}
public function install(): void {
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$charset = $this->wpdb->get_charset_collate();
$table = $this->table();
$sql = "CREATE TABLE {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
amount_cents BIGINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (id),
KEY user_created (user_id, created_at)
) {$charset};";
dbDelta( $sql );
update_option( 'acme_ledger_db_version', self::VERSION, false );
}
}maybe_upgrade() on plugins_loaded, not only on activation. Sites updated by WP-CLI, by a deploy, or across a multisite network never fire the activation hook again, and a table that is one migration behind is the most common cause of a fatal error after an update.Every query against that table goes through $wpdb->prepare() with placeholders. Interpolating a variable into SQL is the fastest way to fail a security review; our guide to running a database query in WordPress shows the placeholder rules in detail.
REST routes with real permission callbacks
Register routes on rest_api_init, give every route an explicit permission_callback, and declare your arguments so the REST server validates and sanitizes before your handler runs.
<?php
namespace Acme\Ledger\Rest;
use Acme\Ledger\Contracts\Bootable;
use WP_REST_Request;
use WP_REST_Response;
final class EntriesController implements Bootable {
public function register_hooks(): void {
add_action( 'rest_api_init', [ $this, 'register_routes' ] );
}
public function register_routes(): void {
register_rest_route(
'acme-ledger/v1',
'/entries',
[
'methods' => 'GET',
'callback' => [ $this, 'index' ],
'permission_callback' => static fn() => current_user_can( 'edit_posts' ),
'args' => [
'per_page' => [
'type' => 'integer',
'default' => 20,
'minimum' => 1,
'maximum' => 100,
'sanitize_callback' => 'absint',
],
],
]
);
}
public function index( WP_REST_Request $request ): WP_REST_Response {
return new WP_REST_Response( [ 'items' => [] ], 200 );
}
}Declaring arguments is not decoration. The REST server runs your validate_callback and sanitize_callback before the handler is invoked and returns a structured 400 response when validation fails, so your controller never sees a malformed request. That is a much better place for input rules than a pile of isset() checks at the top of the method, and it means the schema is discoverable through an OPTIONS request.
Returning __return_true from a permission callback is a published vulnerability waiting to happen. If a route really is public, say so explicitly in a comment and make sure it exposes nothing a logged out visitor could not already read.
Unit tests with WP_Mock, and CI
WP_Mock lets you unit test code that calls WordPress functions without loading WordPress. You declare which functions you expect to be called and what they return, so the test is fast and hermetic.
<?php
namespace Acme\Ledger\Tests\Unit;
use Acme\Ledger\Rest\EntriesController;
use WP_Mock;
use WP_Mock\Tools\TestCase;
final class EntriesControllerTest extends TestCase {
public function test_it_registers_the_rest_hook(): void {
WP_Mock::expectActionAdded( 'rest_api_init', [ WP_Mock\Functions::type( 'object' ), 'register_routes' ] );
( new EntriesController() )->register_hooks();
$this->assertConditionsMet();
}
}
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
php: ['8.1', '8.2', '8.3']
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: none
- run: composer install --prefer-dist --no-progress
- run: vendor/bin/phpcs --standard=WordPress src/
- run: vendor/bin/phpunit --testsuite unitunit suite uses WP_Mock and runs in seconds on every push. A separate integration suite boots a real WordPress with wp-env and a MySQL service, and runs on pull requests only. Mixing them makes the fast feedback loop slow enough that people stop running it.Troubleshooting
Fatal error: class not found, only on the live server. Namespace case or directory case does not match. macOS is case insensitive and Linux is not, so src/Rest/EntriesController.php resolves locally and fails in production. Run composer dump-autoload --optimize and compare the generated classmap against the real paths.
dbDelta silently does nothing. Its parser requires exactly two spaces between PRIMARY KEY and the opening parenthesis, uppercase column types, and no backticks around the table name in the CREATE TABLE line. Echo dbDelta( $sql, false ) to see the queries it would run before committing.
Hooks fire twice. Something is instantiating the service both in the bootstrap and inside another class. With a container this is easy to spot: make get() memoize instances and log a warning when a class is constructed a second time.
REST route returns 404 on a clean install. Permalinks are set to plain, so only the query string form works. Test with ?rest_route=/acme-ledger/v1/entries before assuming registration failed, then flush rewrite rules once on activation.
PHPUnit fails with “Call to undefined function add_action”. The test bootstrap is not calling WP_Mock::bootstrap(), or the class under test calls WordPress functions at file scope. Move all hook registration into a method.
Frequently asked questions
Is a container overkill for a plugin?
For fewer than about five classes, yes. Past that, the container earns its keep the first time you need to swap an implementation in a test or reuse a service in a WP-CLI command. A container in this context is roughly forty lines of code, not a framework dependency.
Should I commit the vendor directory?
For a plugin distributed through WordPress.org, yes, because the directory serves whatever you commit and never runs Composer. Commit a production install created with composer install --no-dev --optimize-autoloader, and keep development dependencies out of the released package entirely.
Are custom tables worth it compared to a custom post type?
Custom post types give you the admin UI, revisions, the REST API, and search for free. Custom tables give you control over indexes and avoid the meta table join problem at scale. Use a post type until profiling proves the meta queries are the bottleneck.
How do I keep the plugin passing WordPress coding standards?
Install wp-coding-standards/wpcs and run phpcs in the pipeline shown above. Expect friction with modern PHP: the standard historically prefers Yoda conditions and snake case method names, so pick a ruleset consciously rather than fighting it file by file.
What changes when I want to publish this?
Nothing structural, but the packaging is different. You build a distribution zip that excludes tests, the Node modules, and development dependencies, then push it through Subversion. Submitting a plugin to the WordPress repository covers readme.txt, the review queue, and the tagging workflow.
The bottom line
Architecture in a WordPress plugin comes down to one discipline: keep the code that knows about WordPress separate from the code that knows about your problem. Autoloading, a container, versioned schema, and validated REST routes are all in service of that separation, and the tests are how you find out whether you achieved it.
Start with the layout and the bootstrap, add the container only when a second service appears, and put the pipeline in place before the codebase is large enough to need it. The official plugin best practices reference is worth rereading once your structure is settled, and if commercial distribution is the goal, our guide on how to sell WordPress plugins picks up where the code stops.
About this article: GeekBlog covers U.S. technology news, AI, phones, smartwatches and gaming. Every story is written and checked under our Editorial Policy. Spotted a mistake or have a story tip? Contact our editors.

