| name | new-plugin |
| description | Scaffold a new WordPress plugin in packages/plugins/. |
Scaffold a New WordPress Plugin
Create a new WordPress plugin in packages/plugins/ following the project's established conventions.
Step 0 — Understand the purpose
Ask the user: "What will this plugin do?" — a brief description of its purpose and the problem it solves.
Based on the description, suggest 2–3 slug and name pairs, for example:
Based on your description, here are some suggestions:
| # | Slug | Name |
|---|
| 1 | event-manager | Event Manager |
| 2 | events | Events |
| 3 | wp-events | WP Events |
Which would you like to use, or do you have your own preference?
- The user may pick one of the suggestions or provide their own slug and name.
- Confirm the final choice before proceeding.
Once the purpose and identity are confirmed, apply the find-plugin skill to determine whether:
- WordPress Core already provides the required functionality natively.
- A well-maintained plugin on WordPress.org already covers the need.
Only proceed to the steps below if Core and existing plugins do not cover the need, or if the user has explicitly agreed to build a custom plugin after reviewing the recommendation.
Step 1 — Gather remaining information
| Input | Required | Notes |
|---|
| Plugin slug | Yes | kebab-case, e.g. acme-blocks. Becomes the directory name, text domain, and Composer package name. |
| Plugin title | Yes | Human-readable name for the Plugin Name: header, e.g. Acme Blocks |
| Plugin description | Yes | Short description for the Description: plugin header |
| PHP namespace | Yes | PascalCase, e.g. Acme\Blocks. Check sibling plugins for the project's convention. |
| Plugin type | Yes | block-library (contains Gutenberg blocks) or general (functionality plugin, no blocks) |
| Project vendor prefix | Yes (block-library only) | e.g. acme — used for block names like acme/hero-banner. Check sibling plugins' block.json files to confirm. |
Scan packages/plugins/ for an existing plugin to use as a structural reference.
Step 2 — Create the directory structure
Block-library plugin:
packages/plugins/<plugin-slug>/
├── plugin.php (plugin header + block auto-loader)
├── src/ (block source directories — see add-block skill)
├── languages/
│ └── .gitkeep
├── package.json
├── composer.json
└── phpcs.xml
General plugin:
packages/plugins/<plugin-slug>/
├── plugin.php (plugin header + bootstrap)
├── src/
│ ├── Plugin.php (main class, singleton)
│ ├── Admin/ (admin screens — add if needed)
│ ├── Api/ (REST controllers — add if needed)
│ └── Frontend/ (front-end hooks — add if needed)
├── languages/
│ └── .gitkeep
├── package.json
├── composer.json
└── phpcs.xml
Step 3 — File contents
plugin.php
Block-library variant:
<?php
declare( strict_types=1 );
namespace <PhpNamespace>;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
foreach ( glob( __DIR__ . '/build/*/block.php' ) as $block ) {
require_once $block;
}
function load_textdomain(): void {
load_plugin_textdomain( '<plugin-slug>', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );
}
add_action( 'init', __NAMESPACE__ . '\\load_textdomain' );
General plugin variant:
<?php
declare( strict_types=1 );
use <PhpNamespace>\Plugin;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
require_once __DIR__ . '/vendor/autoload.php';
Plugin::instance()->hooks();
src/Plugin.php (general plugin only)
<?php
declare( strict_types=1 );
namespace <PhpNamespace>;
class Plugin {
private static ?self $instance = null;
private function __construct() {}
public static function instance(): self {
return self::$instance ??= new self();
}
public function hooks(): void {
add_action( 'init', [ $this, 'init' ] );
add_action( 'init', [ $this, 'load_textdomain' ] );
register_activation_hook( __FILE__, [ $this, 'activate' ] );
register_deactivation_hook( __FILE__, [ $this, 'deactivate' ] );
}
public function init(): void {
}
public function load_textdomain(): void {
load_plugin_textdomain( '<plugin-slug>', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );
}
public function activate(): void {
flush_rewrite_rules();
}
public function deactivate(): void {
flush_rewrite_rules();
}
}
composer.json
{
"name": "dekode/<plugin-slug>",
"description": "<Plugin description>",
"type": "wordpress-plugin",
"version": "1.0.0",
"require": {},
"autoload": {
"psr-4": { "<PhpNamespace>\\": "src/" }
},
"scripts": {
"make-pot": "wp i18n make-pot . languages/<plugin-slug>.pot --domain=<plugin-slug> --exclude=vendor,node_modules,build",
"make-json": "wp i18n make-json languages/<plugin-slug>.pot languages --no-purge",
"lint": "@php vendor/bin/phpcs",
"lint-fix": "@php vendor/bin/phpcbf"
},
"config": {
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true
}
}
}
package.json
{
"name": "@dekode/<plugin-slug>",
"version": "1.0.0",
"private": true,
"description": "<Plugin description>",
"scripts": {
"build": "wp-scripts build --webpack-copy-php",
"start": "wp-scripts start --webpack-copy-php",
"lint:js": "wp-scripts lint-js src",
"lint:css": "wp-scripts lint-style src",
"format": "wp-scripts format src"
},
"devDependencies": {
"@wordpress/scripts": "*"
}
}
- Add any additional
dependencies needed by blocks (e.g. @phosphor-icons/react for icons).
phpcs.xml
<?xml version="1.0"?>
<ruleset name="<Plugin Title>">
<description>PHP CodeSniffer ruleset for <Plugin Title>.</description>
<file>.</file>
<arg name="extensions" value="php"/>
<arg name="basepath" value="."/>
<exclude-pattern>build/</exclude-pattern>
<exclude-pattern>vendor/</exclude-pattern>
<exclude-pattern>node_modules/</exclude-pattern>
<rule ref="../../../phpcs.xml.dist"/>
</ruleset>
The ../../../phpcs.xml.dist path resolves to the root ruleset. Adjust the relative path if the plugin nesting differs.
Step 4 — Register in root workspaces
Root package.json — add the plugin to the workspaces array:
"workspaces": [
"packages/plugins/<plugin-slug>"
]
Root composer.json — add a path repository and require the package:
"repositories": [
{
"type": "path",
"url": "packages/plugins/<plugin-slug>"
}
]
And in require:
"dekode/<plugin-slug>": "*"
Step 5 — Install and verify
composer install
npm install
npm run build
npm run lint:js
npm run lint:css
composer lint
After composer install, confirm the plugin is symlinked into public/content/plugins/<plugin-slug>/ and appears in the WordPress plugin list.
Confirm namespace matches the PSR-4 map in composer.json.
Conventions
- Namespace: Consistent PascalCase — check sibling plugins for the project's convention
- Block auto-loading: Block-library plugins always load blocks from
build/*/block.php via glob — never require individual blocks manually in plugin.php
- No editing
public/: Composer symlinks the plugin automatically; never manually copy files into public/content/
- No editing
vendor/: All dependencies managed by Composer
Output
When done:
packages/plugins/<plugin-slug>/plugin.php — WordPress plugin entry point
packages/plugins/<plugin-slug>/composer.json — PHP package metadata
packages/plugins/<plugin-slug>/package.json — JS build config
packages/plugins/<plugin-slug>/phpcs.xml — PHP linting ruleset
packages/plugins/<plugin-slug>/src/ — source directory (blocks for block-library; PHP classes for general)
packages/plugins/<plugin-slug>/languages/ — translations directory
The plugin is symlinked into public/content/plugins/<plugin-slug>/ after composer install and is ready to receive blocks via the add-block skill.