Close Menu
GeekBlog

    Subscribe to Updates

    Get the latest creative news from FooBar about art, design and business.

    What's Hot

    The Honor Magic 9 Pro Max Shoots in ARRI LogC3. ARRI’s $80,000 ALEXA 35 Has Moved On to LogC4.

    November 11, 2026

    The iPhone Duo Finally Takes an Apple Pencil. Only the $79 One Without Pressure Works.

    November 11, 2026

    A 1960 Equation Says Humanity Hits Infinity This Friday. Here’s How Far Off It Ended Up.

    November 10, 2026
    Facebook
    GeekBlog
    • Home
    • Mobile
    • Tech News
    • Blog
    • Gaming
    • Smartwatch
    • How-To Guides
    • AI & Software
    Facebook
    GeekBlog
    Home»Blog»Building a WordPress Plugin From Scratch (Advanced)
    Blog

    Building a WordPress Plugin From Scratch (Advanced)

    Ethan CaldwellBy Ethan CaldwellSeptember 4, 202611 Mins Read
    Share Facebook Twitter Pinterest LinkedIn Tumblr Email Copy Link
    Close up of source code in a code editor
    Photo via Pexels
    Share
    Facebook Twitter LinkedIn Pinterest Email Copy Link

    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.

    Quick answer: Start with a 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
    LayerMay call WordPress functionsTest strategy
    Domain/NoPlain PHPUnit, no mocks needed
    Database/Yes, $wpdb onlyIntegration tests against a real database
    Rest/, Admin/YesWP_Mock for hook and function expectations
    Root plugin fileYes, minimallyNot 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-dev

    The 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.

    Warning: If two active plugins ship different versions of the same Composer package, the first autoloader registered wins and the second plugin silently gets the wrong class. Prefix your dependencies with a tool such as PHP-Scoper before release, or keep third party packages out of the plugin entirely.

    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.

    Recommended for you:

    MACD Basics: How the Indicator Actually Works
    Blog·Sep 4, 2026

    MACD Basics: How the Indicator Actually Works

    <?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 );
        }
    }
    Note: Run 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 unit
    Tip: Keep two suites. The unit 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.

    Recommended for you:

    How Keltner Channels Are Calculated and Used
    Blog·Sep 4, 2026

    How Keltner Channels Are Calculated and Used

    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.

    Share. Facebook Twitter Pinterest LinkedIn Tumblr Telegram Email Copy Link
    Previous ArticleMACD Basics: How the Indicator Actually Works
    Next Article Using Chaikin Money Flow (CMF) for Scalping
    Ethan Caldwell

      Ethan Caldwell is GeekBlog's resident Apple specialist, covering the entire Apple ecosystem: iPhone, iPad, Mac, Apple Watch, AirPods and the software that ties them together. A longtime iOS user and gadget collector, Ethan tracks Cupertino's every move, breaking down Apple keynotes, A- and M-series chip benchmarks, iOS feature updates and the rumor mill into clear, practical takes that help readers decide whether the latest Apple hardware is worth the upgrade.

      Related Posts

      10 Mins Read

      Google Ads Keyword Planner: How It Shows the Most Relevant Keywords

      11 Mins Read

      How to Use Remarketing Techniques for Better Conversions

      11 Mins Read

      How to Conduct A/B Testing for Marketing Campaigns

      10 Mins Read

      AMP for WP Plugin Vulnerability: What Was Fixed and What to Do

      11 Mins Read

      How to Create a Facebook Business Page in 2026

      11 Mins Read

      How to Convert GMT Time to Other Time Zones in C++

      Top Posts

      Every iPhone Camera Ranked in 2026 (Best to Worst)

      July 6, 2026352 Views

      Best Browser for Android in 2026: Top Picks Ranked

      July 7, 2026270 Views

      iPhone Battery Replacement Cost: Every Model, Apple vs Repair Shop

      October 6, 2026191 Views
      Stay In Touch
      • Facebook

      Subscribe to Updates

      Get the latest tech news from FooBar about tech, design and biz.

      Most Popular

      MapQuest Now Shows the Plate Readers on Your Route. It Won’t Say Where the Map Comes From.

      October 19, 2026638 Views

      Apple Told Its Stores Not to Expect iPhone Duo Walk-Ins. Carriers Can Beat January by Weeks.

      October 20, 2026301 Views

      Europe’s Four Biggest Carriers Want Their Own Starlink. They Won’t Build a Satellite.

      October 20, 2026104 Views
      Our Picks

      The Honor Magic 9 Pro Max Shoots in ARRI LogC3. ARRI’s $80,000 ALEXA 35 Has Moved On to LogC4.

      November 11, 2026

      The iPhone Duo Finally Takes an Apple Pencil. Only the $79 One Without Pressure Works.

      November 11, 2026

      A 1960 Equation Says Humanity Hits Infinity This Friday. Here’s How Far Off It Ended Up.

      November 10, 2026

      Subscribe to Updates

      Get the latest creative news from FooBar about art, design and business.

      HEICJPG.online - Convert HEIC to JPG online
      Facebook
      • About GeekBlog: Our Mission, Team and Editorial Standards
      • Contact us
      • Privacy Policy
      • Disclaimer
      • Terms and Conditions
      • Editorial Policy
      • Cookie Policy
      • Your Privacy Choices
      © 2026 GeekBlog

      Type above and press Enter to search. Press Esc to cancel.