DEV Community

Cover image for Scaffold a WordPress plugin with linting, tests and CI already wired
Akshat Saxena
Akshat Saxena

Posted on AI-assisted

Scaffold a WordPress plugin with linting, tests and CI already wired

Every WordPress plugin I start begins with the same unglamorous hour: PSR-4
autoloading, a PHPCS ruleset, PHPUnit with mocked WordPress functions, a build
step for the admin JS, the GitHub Actions file. Only then do I get to the feature
I actually opened the editor for.

I packaged that hour into a generator. You answer a few prompts, tick the pieces
you need, and get a plugin that already lints, tests and ships.

One command

npx create-wp-plugin-cli
# or: npm create wp-plugin-cli
Enter fullscreen mode Exit fullscreen mode

No install. Node 20+. Generated plugins target PHP 8.2 (fixed) — constructor
promotion, readonly, first-class callables throughout.

The interactive run

Twelve questions. Most have a pre-filled answer you accept with Enter; the two
that matter are the lint target and the modules multiselect.

$ npx create-wp-plugin-cli

🚀 Welcome to create-wp-plugin-cli scaffold generator!

√ 1. Plugin name: … Loyalty Points
√ 2. Plugin slug: … loyalty-points
√ 3. PHP namespace: … LoyaltyPoints
√ 4. Function/constant prefix (>= 4 chars, lowercase): … loyp
√ 5-7. Author name / email / URI …
√ 8. Description (one line): … Award and redeem loyalty points at checkout.
√ 9. Coding standard target for composer lint: › WordPress.org (standard hosting)
√ 10. Include React admin app build pipeline? … no
√ 11. Modules to include (space to toggle):
      › REST API, cron, WP-CLI commands, WooCommerce integration
√ 11a. WooCommerce components: › Payment Gateway, Custom Order Status
√ 12. Output directory: … ./loyalty-points
√ Proceed with these values? … yes

[ok] Successfully scaffolded plugin "Loyalty Points" in ./loyalty-points!
Enter fullscreen mode Exit fullscreen mode

--lint-target picks the PHPCS ruleset composer lint enforces: wp-org
(WordPress-Extra + Docs), vip (WordPress-VIP-Go), or both.

What you always get

Regardless of module choices:

File Role
src/Plugin.php Bootloader with a re-entry guard; boot() is the composition root
src/Core/Activator.php / Deactivator.php Implement Contracts\Activatable / Deactivatable, run from the (de)activation hooks
src/Services.php Static locator of memoised singletons — only emitted if a module needs a shared collaborator
tests/Unit/Plugin_TestCase.php, tests/bootstrap.php Brain Monkey base — unit tests run with no WordPress install
composer.json, phpcs.xml, phpunit.xml.dist PHPCS/WPCS(+VIP), PHPUnit, PHPCompatibility against 8.2-
.github/workflows/ci.yml PHPCS + a PHPUnit 8.2/8.3/8.4 matrix (+ more when there's JS)

The modules

All opt-in, all freely combinable — nothing here depends on anything else.

Admin, content & data

Module What you get
admin_settings A Settings API page split into Admin\Settings_Registrar / Settings_Repository / a view
cpt_taxonomy PostTypes\Post_Types — a CPT + taxonomy, wired into activation
custom_table dbDelta() schema + Database\Schema + an Item_Repository, with version-checked migrations
caching Cache_Service — a persistent object cache or a transient fallback, chosen per call, never both

Front-end

Module What you get
shortcode A Frontend\Shortcode class registering one shortcode
block Native Gutenberg block(s): block:dynamic (render.php) and/or block:static (save()). Blocks\Block_Registrar globs assets/build/blocks/*, so more blocks need no PHP
interactivity A WordPress Interactivity API store — view.js + a Script Module (WP 6.5+)

Integration points

Module What you get
rest_api A WP_REST_Controller subclass with a real permission callback
ajax_handler A nonce- and capability-guarded admin-ajax handler + the assets/js/main.js it enqueues
cron Cron\Scheduler — a scheduled event with a worked example body
cli wp <prefix> status and wp <prefix> cache clear, behind a defined( 'WP_CLI' ) guard
elementor_widget Widget_Registrar auto-discovers src/Widgets/*; convention-based CSS/JS enqueue

Tooling

Module What you get
editor_config .vscode/ snippets, settings, recommended extensions
integration_tests A real wp-phpunit suite (composer test:integration), phpunit-integration.xml.dist, a boot test, .wp-env.json, and a CI integration job

WooCommerce

Pass woocommerce for all nine, or pick components. Each is a Woo\Providers\*
class wired inside a single class_exists( 'WooCommerce' ) guard, so the plugin
is inert on a site without WooCommerce.

Component What you get
woo:gateway A WC_Payment_Gateway subclass + a Blocks (block checkout) payment method type
woo:shipping A WC_Shipping_Method subclass
woo:email A WC_Email subclass + HTML and plain-text templates
woo:order-status A custom, HPOS-compliant order status
woo:product-type A custom product type with data tabs/panels
woo:blocks Cart & Checkout block extensions (front-end script + integration class)
woo:action-scheduler An Action Scheduler-backed background task runner
woo:store-api A Store API extension via ExtendSchema
woo:my-account A custom My Account endpoint (route + template)

A JS pipeline (wp-scripts build + Playwright) is added automatically for
--react, interactivity, block, woo:gateway, or woo:blocks. Jest comes
in with --react. uninstall.php is derived — it ships only when a selected
module actually persists something (an option, a table, a scheduled event).

The architecture, in 60 seconds

Plugin is a bootloader with a re-entry guard. Its boot() is a flat list —
here's the real thing the run above produced:

public function boot(): void {
    if ( $this->booted ) {
        return;
    }
    $this->booted = true;

    if ( defined( 'WP_CLI' ) && WP_CLI ) {
        ( new CLI\Commands() )->init_hooks();
    }

    ( new Rest\Rest_Controller() )->init_hooks();
    ( new Cron\Scheduler() )->init_hooks();

    if ( class_exists( 'WooCommerce' ) ) {
        ( new Woo\Providers\Gateway_Provider() )->init_hooks();
        ( new Woo\Providers\Order_Status_Provider( Services::order_status_service() ) )->init_hooks();
    }
}
Enter fullscreen mode Exit fullscreen mode

A "module" is a plain class with an init_hooks() method. No base class, no
interface, no auto-discovery — a class runs only because boot() names it. CLI
commands sit behind a WP_CLI check; WooCommerce providers behind the
class_exists guard.

Services is emitted only when something needs it — here, because
Order_Status_Provider takes a Services::order_status_service() injection.
Gateway_Provider needs nothing, so it's constructed bare. Services::set() /
reset() are the test seams.

Tests, per module

cd loyalty-points
composer install
composer test
Enter fullscreen mode Exit fullscreen mode

Green on the first run. Every selected module gets its own unit test — the run
above generated Rest_Controller_Test, Commands_Test, Gateway_Test,
Order_Status_Service_Test, plus Services_Test and a Module_Hooks_Test that
asserts each module's init_hooks() registers exactly the hooks it claims. All
Brain Monkey, no WordPress install.

Gateway_Test, for instance, checks the gateway instantiates with id
loyp_gateway and that the unimplemented process_payment() stub fails
closed
— returns ['result' => 'failure'], never marks an order paid.

For anything browser-facing:

npm install && npm run build
npm run test:e2e        # Playwright — `npx @wordpress/env start` for a target site
Enter fullscreen mode Exit fullscreen mode

The CI file it wrote

.github/workflows/ci.yml, unprompted:

  • phpcscomposer validate --strict + composer lint
  • phpunit — a PHP 8.2 / 8.3 / 8.4 matrix running composer test
  • node-buildnpm run build, JS/CSS lint, then Playwright E2E against a wp-env site (only when the selection has a JS side)
  • integration — the wp-phpunit suite, when you picked integration_tests

Using it in scripts

Every answer is a flag:

npx create-wp-plugin-cli --yes \
  --name "Loyalty Points" --prefix loyp --namespace LoyaltyPoints \
  --modules "rest_api,cron,cli,woo:gateway,woo:order-status" \
  --lint-target both \
  --out ./loyalty-points
Enter fullscreen mode Exit fullscreen mode

Piping in without --yes and no TTY exits with an error instead of hanging. A
failure partway through generation rolls back the directory it created.

When not to use it

  • A one-file plugin. If it's 40 lines in a single file, this is overkill.
  • You want a DI container. It deliberately doesn't ship one — modules are plain classes and boot() is the composition root.
  • Block-only projects are usually better served by @wordpress/create-block directly; this wraps it when you also need PHP, tests and CI around the block.

Links

Issues and PRs welcome.

Top comments (0)