agentleFS
iwritec0deiwritec0de/wp-dev/skills/wordpress-engineer/SKILL.md

wordpress-engineer

Skill1 starsChanged 7 months agoMITView on GitHub

This skill should be used when the user asks to "build a WordPress plugin", "develop a WordPress theme", "write a WP_Query", "register a custom post type", "create a Gutenberg block", or mentions "wordpress", "wp_", "theme development", "plugin development", "wp-cli", "hooks", "filters", "actions", "gutenberg", "block editor", "wp_query", "custom post type", "ACF", "woocommerce". Provides WordPress engineering expertise for theme and plugin development, security, performance, and best practices.

The file

Show its rules and text

We split this skill into the rules it gives an agent

15 rules. About 0 are common, 2 are distinctive, and 13 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈2

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 13

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Use get_template_part( 'template-parts/content', get_post_type() ) to load reusable partials.

    line 40
  2. Very specific:

    Never use <script> tags directly in templates.

    line 64
  3. Very specific:

    Use styles in theme.json for global CSS.

    line 70
  4. Very specific:

    Use add_theme_support( 'block-templates' ) is not needed — presence of templates/ signals block theme

    line 72
  5. Distinctive:

    Every plugin must begin with the file header:

    line 78
  6. Very specific:

    Create uninstall.php (not the uninstall hook) for data cleanup — it runs in a clean context:

    line 112
  7. Very specific:

    Use Composer autoloading (spl_autoload_register fallback) for class files.

    line 229
  8. Very specific:

    Always return a value in filter callbacks — omitting the return silently removes the content.

    line 240
  9. Very specific:

    Document every custom hook with a @since tag and parameter descriptions.

    line 273
  10. Very specific:

    Always call wp_reset_postdata() after a custom query loop.

    line 303
  11. Very specific:

    Use delete_transient() when the underlying data changes to prevent stale reads.

    line 330
  12. Distinctive:

    Transients: use for external API responses and slow queries.

    line 352
  13. Very specific:

    Query optimization: avoid meta_query on large tables without an index.

    line 353
  14. Very specific:

    CDN integration: use add_filter( 'wp_calculate_image_srcset', ...

    line 355
  15. Very specific:

    Use InnerBlocks in block edit and save to allow nested content.

    line 400

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

WordPress Engineer Skill

You are a senior WordPress engineer following WordPress Coding Standards and security best practices.

Critical Rules

  • Always escape output — use esc_html(), esc_attr(), esc_url(), wp_kses(), or wp_kses_post() at every output point; never echo raw user-supplied or database-sourced data
  • Always sanitize input — use sanitize_text_field(), sanitize_email(), absint(), wp_strip_all_tags(), or the appropriate typed sanitizer before storing or using any user input
  • Use nonces for all form submissions — generate with wp_nonce_field() or wp_create_nonce(), verify with wp_verify_nonce() or check_admin_referer() before processing
  • Use prepared statements — always pass untrusted data through $wpdb->prepare() before any $wpdb->get_results(), $wpdb->query(), or similar; never interpolate variables directly into SQL
  • Follow WordPress Coding Standards (WPCS) — tabs for indentation, Yoda conditions, space inside parentheses for control structures, snake_case for functions and variables
  • Prefix everything — all functions, classes, hooks, and global variables must carry the project namespace prefix (e.g., myplugin_, MyPlugin_) to avoid collision with core and other plugins
  • Use hooks, not direct modifications — extend WordPress behavior via add_action() and add_filter(); never patch core files or override template files by editing them directly

Theme Development

Template Hierarchy

WordPress resolves templates in order of specificity. Key resolution paths:

  • Single post: single-{post-type}-{slug}.php → single-{post-type}.php → single.php → singular.php → index.php
  • Archive: archive-{post-type}.php → archive.php → index.php
  • Page: page-{slug}.php → page-{id}.php → page.php → singular.php → index.php

Use get_template_part( 'template-parts/content', get_post_type() ) to load reusable partials. Pass data with the third argument array (WP 5.5+).

Script and Style Enqueueing

// In functions.php
add_action( 'wp_enqueue_scripts', 'myplugin_enqueue_assets' );
function myplugin_enqueue_assets(): void {
    wp_enqueue_style(
        'myplugin-main',
        get_theme_file_uri( 'assets/css/main.css' ),
        [],
        wp_get_theme()->get( 'Version' )
    );
    wp_enqueue_script(
        'myplugin-app',
        get_theme_file_uri( 'assets/js/app.js' ),
        [ 'jquery' ],
        wp_get_theme()->get( 'Version' ),
        true  // load in footer
    );
}

Never use <script> tags directly in templates. Never hardcode version numbers — use theme version or filemtime() during development.

Block Themes and theme.json

Block themes replace functions.php enqueues for global styles with theme.json. Key principles:

  • Define color palettes, typography scales, and spacing in theme.json under settings
  • Use styles in theme.json for global CSS; avoid style.css for layout rules
  • Templates live in templates/ as HTML files with block markup; template parts in parts/
  • Use add_theme_support( 'block-templates' ) is not needed — presence of templates/ signals block theme

Plugin Development

Plugin Header

Every plugin must begin with the file header:

<?php
/**
 * Plugin Name:       My Plugin
 * Plugin URI:        https://example.com/my-plugin
 * Description:       One-line description.
 * Version:           1.0.0
 * Requires at least: 6.0
 * Requires PHP:      7.4
 * Author:            Your Name
 * License:           GPL-2.0-or-later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       my-plugin
 */

Activation, Deactivation, and Uninstall

register_activation_hook( __FILE__, 'myplugin_activate' );
function myplugin_activate(): void {
    // Create custom tables, set default options
    // Do NOT perform redirects here
}

register_deactivation_hook( __FILE__, 'myplugin_deactivate' );
function myplugin_deactivate(): void {
    // Clear scheduled events, flush rewrite rules
    wp_clear_scheduled_hook( 'myplugin_daily_event' );
}

Create uninstall.php (not the uninstall hook) for data cleanup — it runs in a clean context:

// uninstall.php
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
    exit;
}
delete_option( 'myplugin_settings' );

OOP Plugin Structure (Standard)

// Main plugin file bootstraps the class
if ( ! class_exists( 'MyPlugin' ) ) {
    require_once plugin_dir_path( __FILE__ ) . 'includes/class-plugin.php';
    MyPlugin::get_instance()->init();
}

// includes/class-plugin.php
class MyPlugin {
    private static ?self $instance = null;

    public static function get_instance(): self {
        if ( null === self::$instance ) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    public function init(): void {
        add_action( 'init', [ $this, 'register_post_types' ] );
        add_filter( 'the_content', [ $this, 'filter_content' ] );
    }
}

OOP Plugin Structure (Large / Namespaced)

For larger plugins, use PSR-4 autoloading and domain-organized directories:

// my-plugin.php — bootstrap
namespace MyPlugin;

defined( 'ABSPATH' ) || exit;

define( 'MYPLUGIN_VERSION', '1.0.0' );
define( 'MYPLUGIN_FILE',    __FILE__ );
define( 'MYPLUGIN_DIR',     plugin_dir_path( __FILE__ ) );
define( 'MYPLUGIN_URL',     plugin_dir_url( __FILE__ ) );

require_once MYPLUGIN_DIR . 'vendor/autoload.php';

register_activation_hook( __FILE__, [ Plugin::class, 'activate' ] );
register_deactivation_hook( __FILE__, [ Plugin::class, 'deactivate' ] );

add_action( 'plugins_loaded', function (): void {
    Plugin::get_instance()->init();
} );
// includes/Plugin.php
namespace MyPlugin;

class Plugin {
    private static ?self $instance = null;
    private bool $initialized = false;

    public static function get_instance(): self {
        if ( null === self::$instance ) {
            self::$instance = new self();
        }
        return self::$instance;
    }

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

        // Register modules by domain
        ( new PostTypes\Event() )->register();
        ( new Admin\Settings() )->register();
        ( new REST\EventController() )->register();

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

    public static function activate(): void {
        ( new Database\Migrator() )->run();
        flush_rewrite_rules();
    }

    public static function deactivate(): void {
        wp_clear_scheduled_hook( 'myplugin_daily_sync' );
    }
}
// composer.json
{
    "autoload": {
        "psr-4": {
            "MyPlugin\\": "includes/"
        }
    }
}

This maps MyPlugin\Admin\Settings → includes/Admin/Settings.php, MyPlugin\PostTypes\Event → includes/PostTypes/Event.php, etc.

Use Composer autoloading (spl_autoload_register fallback) for class files.

Hooks System

Actions vs Filters

ConceptHook TypeReturn valuePurpose
Do somethingadd_action()Not usedSide effects — send email, save data, enqueue asset
Modify somethingadd_filter()RequiredTransform a value before WordPress uses it

Always return a value in filter callbacks — omitting the return silently removes the content.

Priority and Argument Count

// Signature: add_action( $hook, $callback, $priority = 10, $accepted_args = 1 )
add_action( 'save_post', 'myplugin_on_save', 20, 2 );
function myplugin_on_save( int $post_id, \WP_Post $post ): void { ... }

Lower priority runs earlier. Default is 10. Use PHP_INT_MAX to run last, PHP_INT_MIN to run first.

Removing Hooks

// Must match the exact $priority used when adding
remove_action( 'wp_head', [ $object, 'method' ], 10 );

For anonymous functions, removal is impossible — always use named callbacks or store the reference.

Custom Hooks

Define your own hooks to make your plugin extensible:

// Define an action
do_action( 'myplugin_before_process', $data );

// Define a filter
$value = apply_filters( 'myplugin_process_value', $raw_value, $context );

Document every custom hook with a @since tag and parameter descriptions.

Database

WP_Query

$query = new WP_Query( [
    'post_type'      => 'product',
    'posts_per_page' => 12,
    'meta_query'     => [
        [
            'key'     => '_price',
            'value'   => 100,
            'compare' => '>=',
            'type'    => 'NUMERIC',
        ],
    ],
    'no_found_rows'  => true, // skip COUNT(*) when pagination not needed
] );

if ( $query->have_posts() ) {
    while ( $query->have_posts() ) {
        $query->the_post();
        // template output
    }
    wp_reset_postdata();
}

Always call wp_reset_postdata() after a custom query loop. Use no_found_rows => true when you don't need pagination to avoid an expensive COUNT(*) query.

Direct Database Queries

global $wpdb;

// Always use prepare() — no exceptions
$results = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->prefix}my_table WHERE user_id = %d AND status = %s",
        $user_id,
        $status
    )
);

Transient Caching

$data = get_transient( 'myplugin_expensive_data' );
if ( false === $data ) {
    $data = myplugin_fetch_expensive_data();
    set_transient( 'myplugin_expensive_data', $data, HOUR_IN_SECONDS );
}

Use delete_transient() when the underlying data changes to prevent stale reads.

Security Quick Reference

ConcernSolution
Output to HTMLesc_html()
Output to attributeesc_attr()
Output to URLesc_url()
Output rich HTMLwp_kses_post()
Sanitize text inputsanitize_text_field()
Sanitize integerabsint()
Sanitize emailsanitize_email()
Verify form origincheck_admin_referer() / wp_verify_nonce()
Check permissionscurrent_user_can( 'capability' )
Safe SQL$wpdb->prepare()

See reference/security-standards.md for the full checklist.

Performance

  • Object cache: wrap expensive computations with wp_cache_get() / wp_cache_set() using a group and expiry
  • Transients: use for external API responses and slow queries; respect the TTL
  • Query optimization: avoid meta_query on large tables without an index; prefer tax_query which uses indexed taxonomy tables
  • Lazy loading: images get loading="lazy" by default in WP 5.5+; use wp_lazy_loading_enabled filter to control
  • CDN integration: use add_filter( 'wp_calculate_image_srcset', ... ) to rewrite image URLs to CDN; offload static assets via object storage plugins

Block Editor (Gutenberg)

Block Registration

// block.json (in block directory)
{
  "apiVersion": 3,
  "name": "myplugin/my-block",
  "title": "My Block",
  "category": "text",
  "attributes": {
    "content": { "type": "string", "source": "html", "selector": "p" }
  },
  "editorScript": "file:./index.js",
  "style": "file:./style.css"
}
// Register via PHP — WordPress discovers block.json automatically
add_action( 'init', 'myplugin_register_blocks' );
function myplugin_register_blocks(): void {

    register_block_type( plugin_dir_path( __FILE__ ) . 'blocks/my-block/' );
}

Block Patterns and Categories

add_action( 'init', 'myplugin_register_block_patterns' );
function myplugin_register_block_patterns(): void {

    register_block_pattern_category( 'myplugin', [ 'label' => __( 'My Plugin', 'myplugin' ) ] );
    register_block_pattern( 'myplugin/hero', [
        'title'      => __( 'Hero Section', 'myplugin' ),
        'categories' => [ 'myplugin' ],
        'content'    => '<!-- wp:group -->...<!-- /wp:group -->',
    ] );
}

Use InnerBlocks in block edit and save to allow nested content. Define allowedBlocks to constrain the pattern.

WP-CLI

Custom Commands

// Guard with WP_CLI constant — only load in CLI context
if ( defined( 'WP_CLI' ) && WP_CLI ) {
    require_once MYPLUGIN_DIR . 'includes/CLI/Commands.php';
    WP_CLI::add_command( 'myplugin', MyPlugin\CLI\Commands::class );
}
namespace MyPlugin\CLI;

class Commands {
    /**
     * Import data from a CSV file.
     *
     * ## OPTIONS
     *
     * <file>
     * : Path to the CSV file.
     *
     * [--dry-run]
     * : Preview changes without saving.
     *
     * ## EXAMPLES
     *
     *     wp myplugin import data.csv
     *     wp myplugin import data.csv --dry-run
     *
     * @when after_wp_load
     */
    public function import( array $args, array $assoc_args ): void {
        $file    = $args[0];
        $dry_run = ! empty( $assoc_args['dry-run'] );

        if ( ! file_exists( $file ) ) {
            WP_CLI::error( "File not found: $file" );
        }

        // ... processing ...

        WP_CLI::success( 'Import complete.' );
    }

    /**
     * Clear plugin caches.
     *
     * @when after_wp_load
     */
    public function flush_cache(): void {
        delete_transient( 'myplugin_data' );
        WP_CLI::success( 'Cache cleared.' );
    }
}

Useful WP-CLI Commands for Development

# Plugin management
wp plugin activate myplugin
wp plugin deactivate myplugin

# Flush rewrite rules (after CPT changes)
wp rewrite flush

# List/manage options
wp option list --search="myplugin_*"
wp option delete myplugin_settings

# Database operations
wp db query "SELECT * FROM wp_options WHERE option_name LIKE 'myplugin_%'"

# Generate POT file for translations
wp i18n make-pot . languages/myplugin.pot --domain=myplugin

# Scaffold test files
wp scaffold plugin-tests myplugin

# Run cron events
wp cron event run --due-now

# Search-replace (safe with --dry-run)
wp search-replace 'http://old.test' 'https://new.test' --dry-run

Related

  • reference/security-standards.md — Full escaping, sanitization, nonce, capability, and file upload security checklist
  • reference/theme-patterns.md — Block theme structure, template hierarchy, theme.json configuration, FSE patterns, classic theme migration
  • reference/plugin-patterns.md — Plugin architecture, custom post types, taxonomies, meta boxes, REST API endpoints, WP-CLI commands, Settings API

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.