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(), orwp_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()orwp_create_nonce(), verify withwp_verify_nonce()orcheck_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()andadd_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.jsonundersettings - Use
stylesintheme.jsonfor global CSS; avoidstyle.cssfor layout rules - Templates live in
templates/as HTML files with block markup; template parts inparts/ - Use
add_theme_support( 'block-templates' )is not needed — presence oftemplates/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
| 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
// 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
| 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_queryon large tables without an index; prefertax_querywhich uses indexed taxonomy tables - Lazy loading: images get
loading="lazy"by default in WP 5.5+; usewp_lazy_loading_enabledfilter 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 checklistreference/theme-patterns.md— Block theme structure, template hierarchy, theme.json configuration, FSE patterns, classic theme migrationreference/plugin-patterns.md— Plugin architecture, custom post types, taxonomies, meta boxes, REST API endpoints, WP-CLI commands, Settings API