wp-plugin-architecture

Designs and reviews the internal architecture of a WordPress plugin — folder layout, PSR-4 one-class-per-file discipline, Schema/Constants placement, composition-root vs singleton decisions, conditional asset enqueueing, script config via wp_add_inline_script, and prefixed custom-hook naming. Use when scaffolding includes/src, reviewing class organization, asset enqueue code, repeated strings, or "should I make this a singleton" decisions.

Best use case

wp-plugin-architecture is best used when you need a repeatable AI agent workflow instead of a one-off prompt.

Designs and reviews the internal architecture of a WordPress plugin — folder layout, PSR-4 one-class-per-file discipline, Schema/Constants placement, composition-root vs singleton decisions, conditional asset enqueueing, script config via wp_add_inline_script, and prefixed custom-hook naming. Use when scaffolding includes/src, reviewing class organization, asset enqueue code, repeated strings, or "should I make this a singleton" decisions.

Teams using wp-plugin-architecture should expect a more consistent output, faster repeated execution, less prompt rewriting.

When to use this skill

  • You want a reusable workflow that can be run more than once with consistent structure.

When not to use this skill

  • You only need a quick one-off answer and do not need a reusable workflow.
  • You cannot install or maintain the underlying files, dependencies, or repository context.

Installation

Claude Code / Cursor / Codex

$curl -o ~/.claude/skills/wp-plugin-architecture/SKILL.md --create-dirs "https://raw.githubusercontent.com/nvdigitalsolutions/mcp-ai-wpoos/main/.agents/skills/wp-plugin-architecture/SKILL.md"

Manual Installation

  1. Download SKILL.md from GitHub
  2. Place it in .claude/skills/wp-plugin-architecture/SKILL.md inside your project
  3. Restart your AI agent — it will auto-discover the skill

How wp-plugin-architecture Compares

Feature / Agentwp-plugin-architectureStandard Approach
Platform SupportNot specifiedLimited / Varies
Context Awareness High Baseline
Installation ComplexityUnknownN/A

Frequently Asked Questions

What does this skill do?

Designs and reviews the internal architecture of a WordPress plugin — folder layout, PSR-4 one-class-per-file discipline, Schema/Constants placement, composition-root vs singleton decisions, conditional asset enqueueing, script config via wp_add_inline_script, and prefixed custom-hook naming. Use when scaffolding includes/src, reviewing class organization, asset enqueue code, repeated strings, or "should I make this a singleton" decisions.

Where can I find the source code?

You can find the source code on GitHub using the link provided at the top of the page.

SKILL.md Source

# WordPress plugin: internal architecture

How the plugin organizes itself **inside** `includes/` (or wherever the PSR-4 root lives) once the bootstrap (see `wp-plugin-bootstrap`) and lifecycle (see `wp-plugin-lifecycle`) are in place. The bootstrap is the launcher; this skill is the engine layout.

Out of scope: bootstrap-file content, activation / deactivation / uninstall, cron specifics, REST endpoint design — covered by sibling skills.

## When to use this skill

Trigger when ANY of the following is true:

- Scaffolding the `includes/` (or `src/`) folder of a new plugin.
- Reviewing the class layout in a PR — folder structure, where things live, what's reused.
- Deciding whether a meta key / option name belongs in a class const, a `Schema.php`, or a PHP enum.
- Reviewing asset enqueue code — wrong hook, missing dependencies, unconditional loading.
- The user asks "should this be a singleton" or "where should this constant live".

## Folder layout — by-type or by-feature

There are two reasonable organizational schemes for `includes/`. Pick one and stay consistent.

**By-type** (group classes by their WP role). Works for small-to-medium plugins (≤ 15 classes, 1-3 features):

```
includes/
├── Plugin.php              # composition root / wiring
├── Schema.php              # central constants
├── Actions/                # JFB / WC actions
├── Events/                 # JFB events
├── Settings/               # admin settings
├── Rest/                   # REST controllers
└── Api/                    # external HTTP clients
```

**By-feature** (group everything that belongs to a feature together). Scales better past 3-4 distinct features:

```
includes/
├── Plugin.php
├── Schema.php
├── Verdict/                # AI Verdict feature
│   ├── VerdictAction.php
│   ├── VerdictTrueEvent.php
│   └── VerdictFalseEvent.php
├── Enrichment/
│   ├── EnrichmentAction.php
│   └── EnrichmentDoneEvent.php
├── Settings/
│   └── SettingsTab.php
└── Updater/
    └── UpdateChecker.php
```

The wrong move is **mixing both** in the same plugin. A reader gets confused, an AI gets lost, and refactors become tedious. By-type is fine until it isn't — when a third feature ships and `Actions/` has 9 classes from 3 unrelated domains, switch the whole plugin to by-feature.

**Hard rule across both styles:** one class per file, file name matches class name (`VerdictAction.php` → `class VerdictAction`), PSR-4 maps `<RootNamespace>\Verdict\VerdictAction` to `includes/Verdict/VerdictAction.php`. The kebab-case `class-foo-bar.php` filename is a holdover from PHPCS-WPCS-old; modern plugins use PascalCase that matches the class.

## Centralization — `Schema` / `Constants` is non-negotiable

Every string that appears more than once — meta key, option name, custom hook name, cron event name, CPT slug, capability slug, transient prefix — lives in **one place**. Three patterns that all work:

### Class const on the feature class

```php
class UsageTracker {
    public const OPTION_PREFIX = 'myplugin_usage_';

    public static function current_month_key(): string {
        return self::OPTION_PREFIX . gmdate( 'Y_m' );
    }
}
```

When the constant logically belongs to one feature, define it there. Cleanest scope.

### Dedicated `Schema` class for cross-feature constants

```php
namespace MyPlugin;

final class Schema {
    public const META_FORM_SETTINGS = '_myplugin_form_settings';
    public const OPTION_GLOBAL      = 'myplugin_global_settings';
    public const CPT_LOG            = 'myplugin_log';
    public const CRON_DAILY         = 'myplugin_daily_cleanup';
    public const HOOK_BEFORE_REQUEST = 'myplugin/before_request';

    private function __construct() {} // not instantiable
}
```

Then everywhere: `Schema::META_FORM_SETTINGS` instead of the literal string. Renaming becomes one-line; typos become impossible (the class autoloader catches them).

### PHP 8.1+ enum for typed value sets

For a fixed value set (failure modes, output types, etc.), use enums only when
the plugin's declared minimum PHP version is 8.1 or higher:

```php
enum FailureMode: string {
    case Halt        = 'halt';
    case Permissive  = 'permissive';
    case Restrictive = 'restrictive';
}

// Then instead of error-prone string parsing:
$mode = FailureMode::tryFrom( $raw ) ?? FailureMode::Halt;
```

Type-safe, enumerable, documents itself.

For PHP 7.4 / 8.0-compatible plugins, use class constants plus explicit
validation instead:

```php
final class FailureMode {
    public const HALT        = 'halt';
    public const PERMISSIVE  = 'permissive';
    public const RESTRICTIVE = 'restrictive';

    public static function normalize( string $raw ): string {
        return in_array( $raw, self::all(), true ) ? $raw : self::HALT;
    }

    public static function all(): array {
        return array( self::HALT, self::PERMISSIVE, self::RESTRICTIVE );
    }
}
```

**The hard rule:** if you find yourself typing the same magic string in two files, that's the moment to centralize. The wrong direction is "I'll only have it in two places, no need yet" — `git grep` next year proves the lie.

## Singleton discipline — composition root yes, everything else no

The `Plugin` class is often the composition root: one object wires services,
hooks, controllers, settings, and integrations after bootstrap. It MAY expose a
small `instance()` helper when the surrounding plugin style already uses that
pattern, but a singleton is not required. A plain `new Plugin(...)->register()`
from the bootstrap is usually easier to test.

Beyond the composition root, **don't make everything a singleton** by reflex.

- A `SettingsRepository` doesn't need `getInstance()` — instantiate it where you need it (`new SettingsRepository()`); it's cheap.
- A logger MAY be a singleton if it holds connection state (a buffer, a remote handler) — but most plugin loggers wrap `error_log`, which is itself globally available. No state, no singleton.
- An external API client (Stripe, OpenAI, Slack) is NOT a singleton. Inject the API key + dependencies at construction; pass it where it's needed.

The price of singleton-everywhere: tests can't substitute mocks, dependencies become hidden, and you accumulate a `Plugin::instance()->get_storage_manager()->get_provider_registry()` getter chain that nobody can refactor.

When in doubt: write the class as a regular class. Promote to singleton only when there's a concrete reason (genuine global state, expensive lazy initialization shared across many call sites, or compatibility with an existing plugin API).

## Asset enqueueing — conditional, on the right hook

The wrong way: enqueue every script and stylesheet on every page load via `wp_enqueue_scripts`. The right way:

1. **Pick the right hook for the right context.** Verified in WP source:
   - **Frontend pages**: `wp_enqueue_scripts` ([wp-includes/script-loader.php:2311](wp-includes/script-loader.php))
   - **wp-admin pages**: `admin_enqueue_scripts` ([wp-admin/admin-header.php:123](wp-admin/admin-header.php)) — receives `$hook_suffix` argument identifying the current admin page
   - **Block editor (Gutenberg)**: `enqueue_block_editor_assets`
   - **Front + back of blocks**: `enqueue_block_assets`
   - **Login screen**: `login_enqueue_scripts`
   - **Customizer preview**: `customize_preview_init`

2. **Gate by context** inside the hook callback:
   ```php
   add_action( 'admin_enqueue_scripts', static function ( string $hook_suffix ): void {
       // Only on the plugin's own settings page
       if ( $hook_suffix !== 'settings_page_myplugin' ) {
           return;
       }
       wp_enqueue_script( /* ... */ );
   } );
   ```
   For frontend, gate by `is_singular()` / `has_block( 'myplugin/contact' )` / specific shortcodes.

3. **Always declare dependencies** — even ones that "feel obvious". `wp-element`, `wp-components`, `wp-i18n`, `wp-hooks`, your own scripts. Without proper deps the script may run before the dependency is defined and break.

4. **Cache-bust deterministically.** Two valid patterns:
   - `filemtime( $script_path )` — perfect during development (every save invalidates cache).
   - The plugin `VERSION` constant — best for production (predictable, changes on release).
   - A hybrid: `defined('SCRIPT_DEBUG') && SCRIPT_DEBUG ? filemtime() : VERSION`.

5. **`$args` array since WP 6.3** — the 5th parameter overloads from a boolean `$in_footer` to an array supporting `strategy` (`'defer'` or `'async'`), `in_footer`, and `fetchpriority` (since 6.9). Use `'strategy' => 'defer'` instead of jQuery-era ready-handlers when feasible.

### `wp_add_inline_script` preferred over `wp_localize_script` for new code

`wp_localize_script` ([since 2.2](wp-includes/functions.wp-scripts.php)) creates a JavaScript object from an associative array. It still works, but for arbitrary runtime config it is a legacy-shaped helper: it requires an array payload and creates a top-level global. Use `wp_set_script_translations()` for JavaScript translations.

`wp_add_inline_script` ([since 4.5](wp-includes/functions.wp-scripts.php)) is the modern path: any JS string, before or after the registered script. Use `wp_json_encode()` to serialize structured data:

```php
wp_add_inline_script(
    'myplugin-editor',
    'window.MyPluginConfig = ' . wp_json_encode( array(
        'restUrl' => esc_url_raw( rest_url( 'myplugin/v1/' ) ),
        'nonce'   => wp_create_nonce( 'wp_rest' ),
    ) ) . ';',
    'before'
);
```

Both functions still work; for a new plugin, default to `wp_add_inline_script`
for configuration and `wp_set_script_translations()` for script translations.

## Hook naming — prefix everything

Custom action / filter hooks the plugin emits (so other developers can wire into your behavior) follow ONE naming convention, picked once and never deviated from:

```php
// Slash-separated (common in modern plugins and integrations)
do_action( 'myplugin/before_request', $payload );
apply_filters( 'myplugin/api_response', $response, $request );

// Underscore-separated (classic; common in WordPress core)
do_action( 'myplugin_before_request', $payload );
```

Both are common in the ecosystem, but WordPress core itself mostly uses
underscore-separated hook names such as `pre_get_posts` and
`rest_pre_serve_request`. If the project uses WPCS rules that discourage slashes
in hook names, pick underscores. The hard rule: **prefix every custom hook with
the plugin slug**. `do_action( 'before_request', ... )` is a foot-gun —
collisions with other plugins are guaranteed at scale.

When you DO emit a custom hook, give it a docblock right above:

```php
/**
 * Fires before the AI request is sent.
 *
 * @since 1.0.0
 *
 * @param array $payload The request payload (mutable downstream).
 */
do_action( 'myplugin/before_request', $payload );
```

`@since` lets users pin their integration; AI assistants reading the source rely on these to suggest correct hooks.

## Critical rules

- **One class per file**, file name matches class name, PSR-4 namespace mirrors the folder path.
- **Pick by-type or by-feature**, never both in the same plugin. Switch styles by refactoring the whole `includes/` at once.
- **Centralize every repeated string** (meta keys, option names, hook names, cron events, CPT slugs) in a class const, a `Schema` class, or a PHP 8.1+ enum when the plugin requires PHP 8.1+.
- **Plugin class is a composition root; singleton is optional.** Default all other classes to regular classes; promote to singleton only with a concrete reason.
- **Enqueue conditionally on the right hook** (`wp_enqueue_scripts` / `admin_enqueue_scripts($hook_suffix)` / `enqueue_block_editor_assets`); never enqueue unconditionally in `init`.
- **`wp_add_inline_script` over `wp_localize_script`** for shipping config / JSON to JS. Both work; the inline-script path is the modern default.
- **Prefix every custom hook** with the plugin slug. No collision-prone bare names.
- **`$args` array** in `wp_enqueue_script` (since 6.3) — use `'strategy' => 'defer'` where possible.

## Common mistakes

```php
// WRONG — magic strings scattered across files
update_post_meta( $post_id, '_myplugin_settings', $value ); // file A
get_post_meta( $post_id, '_myplugin_setings', true );        // file B (typo!)

// RIGHT — one source of truth
update_post_meta( $post_id, Schema::META_SETTINGS, $value );

// WRONG — enqueue on init unconditionally
add_action( 'init', function () {
    wp_enqueue_script( 'myplugin-frontend', /* ... */ );
} );
// queues assets in broad request contexts and bypasses page-specific gates

// RIGHT — frontend hook + context gate
add_action( 'wp_enqueue_scripts', function () {
    if ( ! has_block( 'myplugin/contact' ) ) return;
    wp_enqueue_script( 'myplugin-frontend', /* ... */ );
} );

// WRONG — singleton by reflex
final class HttpClient {
    private static ?self $instance = null;
    public static function instance(): self { /* ... */ }
}
// 1 plugin = 1 client, can't test, can't swap base URL per call site

// RIGHT — regular class with explicit dependencies
$client = new HttpClient( $api_key, $base_url );
$client->post( '/things', $payload );

// WRONG — bare hook name
do_action( 'before_save', $data );  // collides with 5 other plugins

// RIGHT
do_action( 'myplugin/before_save', $data );
```

## Cross-references

- Run **`wp-plugin-bootstrap`** for the main file (header, autoload, requirements, Plugin bootstrapping).
- Run **`wp-plugin-lifecycle`** for activation / deactivation / `uninstall.php`.
- Run **`wp-security-audit`** on any classes that handle user input — the architecture is irrelevant if the controllers leak.

## What this skill does NOT cover

- Dependency-injection containers (PHP-DI, Symfony Container, etc.). For most WP plugins they're overkill; constructor injection without a container is plenty.
- Service layer architecture beyond "regular class, not singleton" — domain-driven design, hexagonal, etc. are valid choices but are a different conversation.
- Block / Gutenberg-specific architecture (`block.json`, `Edit` / `Save` components) — separate skill territory.
- Test architecture — what kind of tests, where they live, how they boot WP. Adjacent topic, separate skill.
- Performance tuning of asset enqueue (concatenation, defer, preload) beyond what the WP 6.3+ `$args` array enables.

## References

- `wp_enqueue_script`: [wp-includes/functions.wp-scripts.php](wp-includes/functions.wp-scripts.php) — note the `@since 6.3.0` on `$args` array overload.
- `wp_add_inline_script`: [wp-includes/functions.wp-scripts.php](wp-includes/functions.wp-scripts.php) — since WP 4.5.
- `admin_enqueue_scripts` action with `$hook_suffix`: [wp-admin/admin-header.php:123](wp-admin/admin-header.php).
- `wp_enqueue_scripts` action: [wp-includes/script-loader.php:2311](wp-includes/script-loader.php).
- PHP enums: [php.net/manual/en/language.enumerations.php](https://www.php.net/manual/en/language.enumerations.php).

Related Skills

wp-plugin-rewrite-rules

5
from nvdigitalsolutions/mcp-ai-wpoos

Design and review custom WordPress URL rewrites: add_rewrite_rule, add_rewrite_tag, query_vars, CPT/taxonomy rewrite slugs, add_rewrite_endpoint, soft vs hard flushes, rewrite_rules cache behavior, and the rule that flush_rewrite_rules() must not run on every request. Use for custom pretty URLs, CPT permalink 404s, endpoint rewrites, and code containing flush_rewrite_rules or add_rewrite_rule.

wp-plugin-presenter

5
from nvdigitalsolutions/mcp-ai-wpoos

Design and review native presenter classes in WordPress plugins without requiring better-data - converting DTOs or domain objects into REST arrays, admin table rows, JS config payloads, email variables, and public view models with allowlisted fields, context methods, redaction by default, locale/date/number formatting, no DTO mutation, and correct WordPress escaping boundaries. Use when adding FooPresenter, response mappers, admin-row arrays, wp_send_json payloads, rest_ensure_response data, wp_add_inline_script config, or when code returns raw DTOs, WP_Post, WC_Order, get_object_vars, json_encode, or unescaped HTML from controllers.

wp-plugin-options-storage

5
from nvdigitalsolutions/mcp-ai-wpoos

Picks the right WordPress storage primitive for plugin data: options, user/post/term/comment meta, transients, site options, site transients, or custom tables. Covers grouped settings, autoload management, transient TTL rules, serialized/JSON blob trade-offs, multisite storage caveats, and naming conventions. Use when scaffolding settings, choosing persistence for plugin-owned data, or auditing update_option/get_option/get_post_meta/set_transient/autoload usage.

wp-plugin-lifecycle

5
from nvdigitalsolutions/mcp-ai-wpoos

Designs and reviews the three lifecycle events of a WordPress plugin — activation (one-shot setup, dbDelta, add_option seeding, cron schedule, cap seeding), deactivation (reversible cleanup, cron clear via wp_unschedule_hook, never delete user data), and uninstall.php (standalone file, WP_UNINSTALL_PLUGIN guard, no autoloader, full data removal). Multisite-aware patterns using the $network_wide / $network_deactivating callback args, plus the recommendation against register_uninstall_hook in favor of uninstall.php. Use when scaffolding a plugin or debugging ghost cron events / orphan options. Triggers on register_activation_hook, register_deactivation_hook, uninstall.php, WP_UNINSTALL_PLUGIN, dbDelta, wp_unschedule_hook, switch_to_blog.

wp-plugin-hooks

5
from nvdigitalsolutions/mcp-ai-wpoos

Design custom action/filter hooks emitted by a plugin: action vs filter semantics, prefixed names, docblocks, parameter stability, *_ref_array forwarding, and deprecated hook migration. Use when adding, reviewing, evolving, or deprecating a public hook surface. Triggers on do_action, apply_filters, apply_filters_deprecated, do_action_deprecated, apply_filters_ref_array, do_action_ref_array, did_action, did_filter, or hook @since docblocks.

wp-plugin-dto

5
from nvdigitalsolutions/mcp-ai-wpoos

Design and review native DTOs in WordPress plugins without requiring better-data - immutable data carriers, explicit from_array hydration, strict coercion instead of unchecked casts, WP_Error validation failures, sensitive-field discipline, nested DTO arrays, and clear separation from repositories, WP models, presenters, REST controllers, and HTML views. Use when a plugin introduces FooDto, request DTOs, settings DTOs, value objects, admin-row data shapes, REST response source objects, or when reviewing code that passes raw arrays, stdClass, WP_Post, WC_Order, $_POST, post meta, or option arrays through multiple layers. Mentions better-data only as an optional higher-level library; this skill is for native implementations.

wp-plugin-cron

5
from nvdigitalsolutions/mcp-ai-wpoos

Designs and reviews scheduled/background work in WordPress plugins: wp_schedule_event, wp_schedule_single_event, cron_schedules, wp_next_scheduled guards, activation scheduling, deactivation cleanup, WP-Cron pseudo-cron timing, DISABLE_WP_CRON/system cron, multisite per-blog cron, idempotent callbacks, chunking, and Action Scheduler graduation. Use when adding scheduled jobs, debugging late/duplicate cron events, or deciding between WP cron and Action Scheduler.

wp-plugin-bootstrap

5
from nvdigitalsolutions/mcp-ai-wpoos

Scaffolds and reviews the main entry-point PHP file of a WordPress plugin — header (with Requires Plugins for WP 6.5+), ABSPATH guard, file/path/url/version constants, Composer + PSR-4 with manual spl_autoload_register fallback, register_activation_hook requirements check, Plugin class instantiation on plugins_loaded, and the WP 6.7+ rule that translation functions must not trigger before after_setup_theme. Use when scaffolding a new plugin or reviewing its main file. Triggers on Plugin Name headers, register_activation_hook, Requires Plugins, spl_autoload_register, plugins_loaded, or composer.json at the plugin root.

wp-plugin-assets-loading

5
from nvdigitalsolutions/mcp-ai-wpoos

Register and enqueue WordPress plugin scripts/styles with modern loading behavior, especially WP 6.9 fetchpriority support, script module args, footer placement, inline style limits, and removal of legacy IE conditional asset support. Covers wp_enqueue_script args strategy/in_footer/fetchpriority, wp_register_script_module / wp_enqueue_script_module args, wp_script_add_data, wp_style_add_data, dependency handles, conditional enqueueing on the right hook, and avoiding global frontend/admin asset bloat. Use when adding or reviewing plugin JS/CSS enqueue code.

webapp-testing

5
from nvdigitalsolutions/mcp-ai-wpoos

Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs.

web-artifacts-builder

5
from nvdigitalsolutions/mcp-ai-wpoos

Suite of tools for creating elaborate, multi-component claude.ai HTML artifacts using modern frontend web technologies (React, Tailwind CSS, shadcn/ui). Use for complex artifacts requiring state management, routing, or shadcn/ui components - not for simple single-file HTML/JSX artifacts.

valyu

5
from nvdigitalsolutions/mcp-ai-wpoos

Search the live web and 36+ specialised data sources including SEC filings, PubMed, ChEMBL, clinical trials, FRED economic indicators, and patent databases. Use when current, authoritative, or paywalled data is required.