Use when creating WordPress plugins: hooks, admin interfaces, custom tables, REST endpoints, Abilities API, AI Client integration, PHP-only blocks, security hardening, or plugin test setup. Do not use for theme templates or storefront setup; use wordpress-theme-development or wordpress-woocommerce-development.
63
75%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Fix and improve this skill with Tessl
tessl review fix ./skills/wordpress-skills/wordpress-plugin-development/SKILL.mdSpecialized workflow for creating WordPress plugins with proper architecture, hooks system, admin interfaces, REST API endpoints, and security practices. Now includes WordPress 7.0 features for modern plugin development.
Real-Time Collaboration (RTC) — did not ship in 7.0
show_in_rest => true so your data is RTC-ready when the feature landsAI Client / Connector Integration
wp_ai_client_prompt()Abilities API
/wp-json/abilities/v1/manifestwordpress/mcp-adapter) exposes Abilities as MCP toolsDataViews & DataForm
PHP-Only Blocks
Use this workflow when:
load_plugin_textdomain() when hosted on WordPress.org:
WordPress 4.6+ automatically loads translations just-in-time (JIT) under your plugin slug from translate.wordpress.org. Manually calling load_plugin_textdomain() is discouraged and flagged by WordPress.org Plugin Check (PluginCheck.CodeAnalysis.DiscouragedFunctions.load_plugin_textdomainFound).load_textdomain() when explicitly loading a bundled custom/fallback catalog (e.g. for a packaged locale file directly in languages/)./*
Plugin Name: My Plugin
Plugin URI: https://example.com/my-plugin
Description: A WordPress 7.0 compatible plugin with AI and MCP support
Version: 1.0.0
Requires at least: 6.0
Requires PHP: 7.4
Author: Developer Name
License: GPL2+
*/import { DataViews } from '@wordpress/dataviews';
const MyPluginDataView = () => {
const data = [/* records */];
const fields = [
{ id: 'title', label: 'Title', sortable: true },
{ id: 'status', label: 'Status', filterBy: true }
];
const view = {
type: 'table',
perPage: 10,
sort: { field: 'title', direction: 'asc' }
};
return (
<DataViews
data={data}
fields={fields}
view={view}
onChangeView={handleViewChange}
/>
);
};$wpdb Best Practices (WordPress 6.2+ & Plugin Check)%i Identifier Placeholders: Always use %i for table and column names in $wpdb->prepare(). Never use PHP string interpolation ("SELECT * FROM {$this->table}"), which triggers WordPress.DB.PreparedSQL.InterpolatedNotPrepared.// Correct (WordPress 6.2+):
$wpdb->get_row(
$wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, (int) $id ),
ARRAY_A
);get_posts), so direct $wpdb calls are expected. However, WordPress.DB.DirectDatabaseQuery and caching sniffs will flag them. Annotate legitimate custom table operations:// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom database table query.
$wpdb->insert( $this->table, $row, $formats );PluginCheck.Security.DirectDB.UnescapedDBParameter):
$wpdb->query(), $wpdb->get_results(), $wpdb->get_var().$where or $orderby, sanitize column names via an explicit whitelist and add the specific ignore annotation:// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where clauses contain safe placeholders and values are bound via prepare().
$total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i {$where}", $query_params ) );IN (...) Placeholders & Complex Queries:
IN ({$placeholders})) and argument arrays can cause static sniff token mismatches (ReplacementsWrongNumber, UnfinishedPrepare).phpcs:disable and phpcs:enable:// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
$sql = $wpdb->prepare(
"SELECT history.* FROM {$this->table} AS history
INNER JOIN ( ... WHERE product_id IN ({$placeholders}) ) ...",
$params
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPreparephpcs:ignore applies only to the immediate next line. When $wpdb->prepare() or SQL strings span multiple lines, place the comment on the line where the token starts or wrap the statement.// Register meta exposed via the REST API
register_post_meta('post', 'my_custom_field', [
'type' => 'string',
'single' => true,
'show_in_rest' => true, // Expose via REST; keeps data RTC-ready
'sanitize_callback' => 'sanitize_text_field',
]);
// For WP 7.0, also consider:
register_term_meta('category', 'my_term_field', [
'type' => 'string',
'show_in_rest' => true,
]);WordPressVIPMinimum)WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude inspects any PHP array for keys 'exclude' and 'post__not_in'.wc_get_products(), or custom catalog option arrays will trigger false positives.// Form options array:
'exclude' => __( 'All except selected', 'my-plugin' ), // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- Form dropdown option, not WP_Query.
// WooCommerce query array:
'exclude' => array_map( 'intval', $exclude_ids ), // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- WooCommerce wc_get_products argument.// Using WordPress 7.0 AI Connector
add_action('save_post', 'my_plugin_generate_ai_summary', 10, 2);
function my_plugin_generate_ai_summary($post_id, $post) {
if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
return;
}
// Check if AI client is available
if (!function_exists('wp_ai_client_prompt')) {
return;
}
$content = strip_tags($post->post_content);
if (empty($content)) {
return;
}
// Build prompt - direct string concatenation for input
// The AI client returns a fluent builder; chain configuration and generation.
// Errors surface from generate_text() as WP_Error.
$summary = wp_ai_client_prompt(
'Create a compelling 2-sentence summary for social media: ' . substr($content, 0, 1000)
)
->using_temperature(0.3) // Set temperature for consistent output
->generate_text();
if ($summary && !is_wp_error($summary)) {
update_post_meta($post_id, '_ai_summary', sanitize_textarea_field($summary));
}
}// Register ability categories on their own hook
add_action('wp_abilities_api_categories_init', function() {
wp_register_ability_category('content-creation', [
'label' => __('Content Creation', 'my-plugin'),
'description' => __('Abilities for generating and managing content', 'my-plugin'),
]);
});
// Register abilities on their own hook
add_action('wp_abilities_api_init', function() {
wp_register_ability('my-plugin/generate-summary', [
'label' => __('Generate Summary', 'my-plugin'),
'description' => __('Creates an AI-powered summary of content', 'my-plugin'),
'category' => 'content-creation',
'input_schema' => [
'type' => 'object',
'properties' => [
'content' => ['type' => 'string'],
'length' => ['type' => 'integer', 'default' => 2]
],
'required' => ['content']
],
'output_schema' => [
'type' => 'object',
'properties' => [
'summary' => ['type' => 'string']
]
],
'execute_callback' => 'my_plugin_generate_summary_cb',
'permission_callback' => function() {
return current_user_can('edit_posts');
}
]);
});
// Handler callback
function my_plugin_generate_summary_cb($input) {
$content = isset($input['content']) ? $input['content'] : '';
$length = isset($input['length']) ? absint($input['length']) : 2;
if (empty($content)) {
return new WP_Error('empty_content', 'No content provided');
}
if (!function_exists('wp_ai_client_prompt')) {
return new WP_Error('ai_unavailable', 'AI not available');
}
$prompt = sprintf('Create a %d-sentence summary of: %s', $length, substr($content, 0, 2000));
$result = wp_ai_client_prompt($prompt)
->using_temperature(0.3)
->generate_text();
if (is_wp_error($result)) {
return $result;
}
return ['summary' => sanitize_textarea_field($result)];
}// Register block entirely in PHP (WordPress 7.0)
// Note: For full PHP-only blocks, use block.json with PHP render_callback
// First, create a block.json file in build/ or includes/blocks/
// Then register in PHP:
// Simple PHP-only block registration (WordPress 7.0+)
if (function_exists('register_block_type')) {
register_block_type('my-plugin/featured-post', [
'render_callback' => function($attributes, $content, $block) {
$post_id = isset($attributes['postId']) ? absint($attributes['postId']) : 0;
if (!$post_id) {
$post_id = get_the_ID();
}
$post = get_post($post_id);
if (!$post) {
return '';
}
$title = esc_html($post->post_title);
$excerpt = esc_html(get_the_excerpt($post));
return sprintf(
'<div class="featured-post"><h2>%s</h2><p>%s</p></div>',
$title,
$excerpt
);
},
'attributes' => [
'postId' => ['type' => 'integer', 'default' => 0],
'showExcerpt' => ['type' => 'boolean', 'default' => true]
],
]);
}// Only if you have the collaboration feature plugin active and must opt a
// post type out. There is no core sync.providers filter in WordPress 7.0.
import { addFilter } from '@wordpress/hooks';
addFilter(
'sync.providers',
'my-plugin/disable-collab',
() => []
);WordPress/plugin-check), running WordPressCS, VIPCS, and PluginCheck sniffs.phpcs.xml.dist <exclude-pattern> tags. If you exclude src/Database/* locally, WordPress.org will still audit every file and fail the submission.wp plugin check <plugin-slug>If your plugin registers Abilities, the official MCP Adapter can expose them to AI agents (Claude Code, Cursor, VS Code, Claude Desktop) as MCP tools. This is the supported path — do not build a bespoke MCP server.
wp_register_ability('my-plugin/generate-summary', [
// ...label, schemas, callbacks...
'meta' => [
'mcp' => [
'public' => true, // Required for default-server MCP access
],
],
]);wp_register_ability_args filter.wordpress/mcp-adapter via Composer, initialize
WP\MCP\Core\McpAdapter::instance(), and create a custom server on the
mcp_adapter_init action with create_server() (explicitly listing the abilities
to expose). Use Jetpack Autoloader if multiple plugins may bundle the adapter.wp mcp-adapter serve, or HTTP via
@automattic/mcp-wordpress-remote) is documented in the wordpress skill.permission_callback, validate/sanitize all input, and log executions. Start with
read-only abilities before enabling anything that mutates state.plugin-name/
├── plugin-name.php
├── includes/
│ ├── class-plugin.php
│ ├── class-loader.php
│ ├── class-activator.php
│ └── class-deactivator.php
├── admin/
│ ├── class-plugin-admin.php
│ ├── css/
│ └── js/
├── public/
│ ├── class-plugin-public.php
│ ├── css/
│ └── js/
├── blocks/ # PHP-only blocks (WP 7.0)
├── abilities/ # Abilities API
├── ai/ # AI Connector integration
├── languages/
└── vendor/show_in_rest => truewatch() not effectwordpress - Full WordPress development workflowwordpress-theme-development - Theme development workflowwordpress-woocommerce-development - WooCommerce development workflow1b92900
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.