| name | wordpress-engineer |
| description | 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. |
| license | MIT |
| metadata | {"author":"Chris Kelley (hello@iwritecode.io)","version":"1.0.0"} |
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
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
Activation, Deactivation, and Uninstall
register_activation_hook( __FILE__, 'myplugin_activate' );
function myplugin_activate(): void {
}
register_deactivation_hook( __FILE__, 'myplugin_deactivate' );
function myplugin_deactivate(): void {
wp_clear_scheduled_hook( 'myplugin_daily_event' );
}
Create uninstall.php (not the uninstall hook) for data cleanup — it runs in a clean context:
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit;
}
delete_option( 'myplugin_settings' );
OOP Plugin Structure (Standard)
if ( ! class_exists( 'MyPlugin' ) ) {
require_once plugin_dir_path( __FILE__ ) . 'includes/class-plugin.php';
MyPlugin::get_instance()->init();
}
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:
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();
} );
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;
( 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' );
}
}
{
"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
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
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:
do_action( 'myplugin_before_process', $data );
$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,
] );
if ( $query->have_posts() ) {
while ( $query->have_posts() ) {
$query->the_post();
}
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;
$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
| 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
{
"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"
}
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
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 {
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" );
}
WP_CLI::success( 'Import complete.' );
}
public function flush_cache(): void {
delete_transient( 'myplugin_data' );
WP_CLI::success( 'Cache cleared.' );
}
}
Useful WP-CLI Commands for Development
wp plugin activate myplugin
wp plugin deactivate myplugin
wp rewrite flush
wp option list --search="myplugin_*"
wp option delete myplugin_settings
wp db query "SELECT * FROM wp_options WHERE option_name LIKE 'myplugin_%'"
wp i18n make-pot . languages/myplugin.pot --domain=myplugin
wp scaffold plugin-tests myplugin
wp cron event run --due-now
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