# Wordpress Engineer

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

- Skill: `iwritec0de/wordpress-engineer` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add iwritec0de/wordpress-engineer`
- Raw SKILL.md: https://api.skillmd.com/api/skills/iwritec0de/wordpress-engineer/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- License: MIT
- Author: iwritec0de (https://skillmd.com/u/iwritec0de)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/iwritec0de/wordpress-engineer

---


# 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

```php
// 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
<?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

```php
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:

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

### OOP Plugin Structure (Standard)

```php
// 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:

```php
// 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();
} );
```

```php
// 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' );
    }
}
```

```json
// 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

| Concept | Hook Type | Return value | Purpose |
|---------|-----------|--------------|---------|
| Do something | `add_action()` | Not used | Side effects — send email, save data, enqueue asset |
| Modify something | `add_filter()` | **Required** | Transform a value before WordPress uses it |

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

### Priority and Argument Count

```php
// 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

```php
// 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:

```php
// 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

```php
$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

```php
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

```php
$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

| Concern | Solution |
|---------|---------|
| Output to HTML | `esc_html()` |
| Output to attribute | `esc_attr()` |
| Output to URL | `esc_url()` |
| Output rich HTML | `wp_kses_post()` |
| Sanitize text input | `sanitize_text_field()` |
| Sanitize integer | `absint()` |
| Sanitize email | `sanitize_email()` |
| Verify form origin | `check_admin_referer()` / `wp_verify_nonce()` |
| Check permissions | `current_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

```js
// 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"
}
```

```php
// 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

```php
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

```php
// 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 );
}
```

```php
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

```bash
# 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

