wordpress-plugin-core by jezweb/claude-skills
npx skills add https://github.com/jezweb/claude-skills --skill wordpress-plugin-core最后更新:2026-01-21 最新版本要求:WordPress 6.9+ (2025年12月2日),推荐 PHP 8.0+,兼容 PHP 8.5 依赖项:无 (最低要求 WordPress 5.9+, PHP 7.4+)
架构模式:简单(仅函数,<5个函数) | OOP(中型插件) | PSR-4(现代/大型,推荐 2025+)
插件头部(仅插件名是必需的):
<?php
/**
* Plugin Name: My Plugin
* Version: 1.0.0
* Requires at least: 5.9
* Requires PHP: 7.4
* Text Domain: my-plugin
*/
if ( ! defined( 'ABSPATH' ) ) exit;
安全基础(在编写功能前必须完成的5个要点):
// 1. 唯一前缀
define( 'MYPL_VERSION', '1.0.0' );
function mypl_init() { /* code */ }
add_action( 'init', 'mypl_init' );
// 2. ABSPATH 检查(每个 PHP 文件)
if ( ! defined( 'ABSPATH' ) ) exit;
// 3. 随机数
wp_nonce_field( 'mypl_action', 'mypl_nonce' );
wp_verify_nonce( $_POST['mypl_nonce'], 'mypl_action' );
// 4. 净化输入,转义输出
$clean = sanitize_text_field( $_POST['input'] );
echo esc_html( $output );
// 5. 预处理语句
global $wpdb;
$wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}table WHERE id = %d", $id ) );
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
应用于:函数、类、常量、选项、瞬态、元键。避免:wp_、__、_。
function mypl_function() {} // ✅
class MyPL_Class {} // ✅
function init() {} // ❌ 会发生冲突
// ❌ 错误 - 安全漏洞
if ( is_admin() ) { /* 删除数据 */ }
// ✅ 正确
if ( current_user_can( 'manage_options' ) ) { /* 删除数据 */ }
常用:manage_options(管理员)、edit_posts(编辑/作者)、read(订阅者)
// 净化 INPUT
$name = sanitize_text_field( $_POST['name'] );
$email = sanitize_email( $_POST['email'] );
$html = wp_kses_post( $_POST['content'] ); // 允许安全的 HTML
$ids = array_map( 'absint', $_POST['ids'] );
// 验证 LOGIC
if ( ! is_email( $email ) ) wp_die( '无效' );
// 转义 OUTPUT
echo esc_html( $name );
echo '<a href="' . esc_url( $url ) . '">';
echo '<div class="' . esc_attr( $class ) . '">';
// 表单
<?php wp_nonce_field( 'mypl_action', 'mypl_nonce' ); ?>
if ( ! wp_verify_nonce( $_POST['mypl_nonce'], 'mypl_action' ) ) wp_die( '失败' );
// AJAX
check_ajax_referer( 'mypl-ajax-nonce', 'nonce' );
wp_localize_script( 'mypl-script', 'mypl_ajax_object', array(
'ajaxurl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'mypl-ajax-nonce' ),
) );
// ❌ SQL 注入
$wpdb->get_results( "SELECT * FROM table WHERE id = {$_GET['id']}" );
// ✅ 预处理 (%s=字符串, %d=整数, %f=浮点数)
$wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}table WHERE id = %d", $_GET['id'] ) );
// LIKE 查询
$search = '%' . $wpdb->esc_like( $term ) . '%';
$wpdb->get_results( $wpdb->prepare( "... WHERE title LIKE %s", $search ) );
✅ 使用唯一前缀(4-5个字符)用于所有全局代码(函数、类、选项、瞬态)
✅ 在每个 PHP 文件添加 ABSPATH 检查:if ( ! defined( 'ABSPATH' ) ) exit;
✅ 检查权限(current_user_can())而不仅仅是 is_admin()
✅ 验证所有表单和 AJAX 请求的随机数
✅ 对所有包含用户输入的数据库查询使用 $wpdb->prepare()
✅ 在保存前使用 sanitize_*() 函数净化输入
✅ 在显示前使用 esc_*() 函数转义输出
✅ 在激活时注册自定义文章类型后刷新重写规则
✅ 使用 uninstall.php 进行永久清理(而非停用钩子)
✅ 遵循 WordPress 编码标准(缩进使用制表符,Yoda 条件)
❌ 绝不要使用 extract() - 会造成安全漏洞
❌ 绝不要信任未经净化的 $_POST/$_GET
❌ 绝不要将用户输入直接拼接到 SQL 中 - 始终使用 prepare()
❌ 绝不要单独使用 is_admin() 进行权限检查
❌ 绝不要输出未净化的数据 - 始终转义
❌ 绝不要使用通用的函数/类名 - 始终添加前缀
❌ 绝不要使用简短的 PHP 标签 <? 或 <?= - 只使用 <?php
❌ 绝不要在停用时删除用户数据 - 仅在卸载时删除
❌ 绝不要重复注册卸载钩子 - 仅在激活时注册一次
❌ 绝不要在主要流程中使用 register_uninstall_hook() - 改用 uninstall.php
此技能可预防 29 个已记录的问题:
错误:通过未转义的用户输入导致数据库受损
来源:https://patchstack.com/articles/sql-injection/(占所有漏洞的15%)
原因:将用户输入直接拼接到 SQL 查询中
预防:始终使用带有占位符的 $wpdb->prepare()
// 易受攻击
$wpdb->query( "DELETE FROM {$wpdb->prefix}table WHERE id = {$_GET['id']}" );
// 安全
$wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}table WHERE id = %d", $_GET['id'] ) );
错误:在用户浏览器中执行恶意 JavaScript 来源:https://patchstack.com(占所有漏洞的35%) 原因:将未净化的用户数据输出到 HTML 预防:始终使用适合上下文的函数转义输出
// 易受攻击
echo $_POST['name'];
echo '<div class="' . $_POST['class'] . '">';
// 安全
echo esc_html( $_POST['name'] );
echo '<div class="' . esc_attr( $_POST['class'] ) . '">';
错误:以用户身份执行未经授权的操作
来源:https://blog.nintechnet.com/25-wordpress-plugins-vulnerable-to-csrf-attacks/
原因:未验证请求是否源自您的网站
预防:使用 wp_nonce_field() 和 wp_verify_nonce() 配合随机数
// 易受攻击
if ( $_POST['action'] == 'delete' ) {
delete_user( $_POST['user_id'] );
}
// 安全
if ( ! wp_verify_nonce( $_POST['nonce'], 'mypl_delete_user' ) ) {
wp_die( '安全检查失败' );
}
delete_user( absint( $_POST['user_id'] ) );
错误:普通用户可以访问管理员功能
来源:WordPress 安全审查指南
原因:使用 is_admin() 而不是 current_user_can()
预防:始终检查权限,而不仅仅是管理员上下文
// 易受攻击
if ( is_admin() ) {
// 任何登录用户都可以触发此操作
}
// 安全
if ( current_user_can( 'manage_options' ) ) {
// 只有管理员可以触发此操作
}
错误:在 WordPress 上下文之外执行 PHP 文件 来源:WordPress 插件手册 原因:文件顶部没有 ABSPATH 检查 预防:在每个 PHP 文件顶部添加 ABSPATH 检查
// 添加到每个 PHP 文件的顶部
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
错误:函数/类与其他插件冲突 来源:WordPress 编码标准 原因:没有唯一前缀的通用名称 预防:在所有全局代码上使用 4-5 个字符的前缀
// 导致冲突
function init() {}
class Settings {}
add_option( 'api_key', $value );
// 安全
function mypl_init() {}
class MyPL_Settings {}
add_option( 'mypl_api_key', $value );
错误:自定义文章类型返回 404 错误,或因重复刷新导致数据库过载 来源:WordPress 插件手册,Permalink Manager Pro 原因:注册 CPT 后忘记刷新重写规则,或者在每次页面加载时都调用刷新 预防:仅在激活/停用时刷新,绝不要在每次页面加载时刷新
// ✅ 正确 - 仅在激活时刷新
function mypl_activate() {
mypl_register_cpt();
flush_rewrite_rules();
}
register_activation_hook( __FILE__, 'mypl_activate' );
function mypl_deactivate() {
flush_rewrite_rules();
}
register_deactivation_hook( __FILE__, 'mypl_deactivate' );
// ❌ 错误 - 导致每次页面加载时数据库过载
add_action( 'init', 'mypl_register_cpt' );
add_action( 'init', 'flush_rewrite_rules' ); // 糟糕!性能杀手!
// ❌ 错误 - 在 functions.php 中
function mypl_register_cpt() {
register_post_type( 'book', ... );
flush_rewrite_rules(); // 糟糕!每次都会运行
}
面向用户的修复:如果 CPT 显示 404,请手动刷新:设置 → 固定链接 → 保存更改。
错误:数据库累积过期的瞬态 来源:WordPress 瞬态 API 文档 原因:卸载时没有清理 预防:在 uninstall.php 中删除瞬态
// uninstall.php
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit;
}
global $wpdb;
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_mypl_%'" );
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_mypl_%'" );
错误:不必要的资源加载导致性能下降 来源:WordPress 性能最佳实践 原因:没有条件检查就入队脚本/样式 预防:仅在需要的地方加载资源
// 不好 - 在每个页面加载
add_action( 'wp_enqueue_scripts', function() {
wp_enqueue_script( 'mypl-script', $url );
} );
// 好 - 仅在特定页面加载
add_action( 'wp_enqueue_scripts', function() {
if ( is_page( 'my-page' ) ) {
wp_enqueue_script( 'mypl-script', $url, array( 'jquery' ), '1.0', true );
}
} );
错误:恶意数据存储在数据库中
来源:WordPress 数据验证
原因:保存 $_POST 数据时没有净化
预防:在保存前始终净化
// 易受攻击
update_option( 'mypl_setting', $_POST['value'] );
// 安全
update_option( 'mypl_setting', sanitize_text_field( $_POST['value'] ) );
错误:SQL 语法错误或注入漏洞
来源:WordPress $wpdb 文档
原因:LIKE 通配符没有正确转义
预防:使用 $wpdb->esc_like()
// 错误
$search = '%' . $term . '%';
// 正确
$search = '%' . $wpdb->esc_like( $term ) . '%';
$results = $wpdb->get_results( $wpdb->prepare( "... WHERE title LIKE %s", $search ) );
错误:变量冲突和安全漏洞 来源:WordPress 编码标准 原因:extract() 从数组键创建变量 预防:绝不要使用 extract(),直接访问数组元素
// 危险
extract( $_POST );
// 现在 $any_array_key 变成了一个变量
// 安全
$name = isset( $_POST['name'] ) ? sanitize_text_field( $_POST['name'] ) : '';
错误:端点对所有人可访问,允许未经授权的访问或权限提升
来源:WordPress REST API 手册,Patchstack CVE 数据库
原因:未指定 permission_callback,或对于敏感端点缺少 show_in_index => false
预防:始终添加 permission_callback 并从 REST 索引中隐藏敏感端点
2025-2026 年真实漏洞:
All in One SEO (300万+站点):缺少权限检查允许贡献者级别的用户查看全局 AI 访问令牌
AI Engine 插件 (CVE-2025-11749, CVSS 9.8 严重):未能设置 show_in_index => false,在 /wp-json/ 索引中暴露了承载令牌,向未经身份验证的攻击者授予了完全管理员权限
SureTriggers:授权检查不足,在披露后4小时内被利用
Worker for Elementor (CVE-2025-66144):订阅者级别的权限可以调用受限功能
// ❌ 易受攻击 - 缺少 permission_callback (WordPress 5.5+ 要求必须有!) register_rest_route( 'myplugin/v1', '/data', array( 'methods' => 'GET', 'callback' => 'my_callback', ) );
// ✅ 安全 - 基本保护 register_rest_route( 'myplugin/v1', '/data', array( 'methods' => 'GET', 'callback' => 'my_callback', 'permission_callback' => function() { return current_user_can( 'edit_posts' ); }, ) );
// ✅ 安全 - 从 REST 索引中隐藏敏感端点 register_rest_route( 'myplugin/v1', '/admin', array( 'methods' => 'POST', 'callback' => 'my_admin_callback', 'permission_callback' => function() { return current_user_can( 'manage_options' ); }, 'show_in_index' => false, // 不要在 /wp-json/ 中暴露 ) );
2025-2026 年统计数据:共跟踪到 64,782 个漏洞,一周内新增 333 个,236 个仍未修补。REST API 认证问题占很大比例。
错误:每次页面加载时都会写入选项 来源:WordPress 插件手册 原因:在主流程中调用了 register_uninstall_hook() 预防:改用 uninstall.php 文件
// 不好 - 在每次页面加载时运行
register_uninstall_hook( __FILE__, 'mypl_uninstall' );
// 好 - 使用 uninstall.php 文件(推荐方法)
// 在插件根目录创建 uninstall.php
错误:用户暂时禁用插件时丢失数据 来源:WordPress 插件开发最佳实践 原因:混淆了停用与卸载 预防:仅在 uninstall.php 中删除数据,绝不要在停用时删除
// 错误 - 在停用时删除用户数据
register_deactivation_hook( __FILE__, function() {
delete_option( 'mypl_user_settings' );
} );
// 正确 - 仅在停用时清除临时数据
register_deactivation_hook( __FILE__, function() {
delete_transient( 'mypl_cache' );
} );
// 正确 - 在 uninstall.php 中删除所有数据
错误:插件在 WordPress 更新时崩溃 来源:WordPress 已弃用函数列表 原因:使用在新版 WordPress 中已移除的函数 预防:在开发期间启用 WP_DEBUG
// 在 wp-config.php 中(仅限开发)
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', false );
错误:翻译未加载 来源:WordPress 国际化 原因:文本域与插件 slug 不匹配 预防:在所有地方使用确切的插件 slug
// 插件头部
// Text Domain: my-plugin
// 在代码中 - 必须完全匹配
__( 'Text', 'my-plugin' );
_e( 'Text', 'my-plugin' );
错误:所需插件未激活时出现致命错误 来源:WordPress 插件依赖项 原因:没有检查所需插件 预防:在 plugins_loaded 时检查依赖项
add_action( 'plugins_loaded', function() {
if ( ! class_exists( 'WooCommerce' ) ) {
add_action( 'admin_notices', function() {
echo '<div class="error"><p>我的插件需要 WooCommerce。</p></div>';
} );
return;
}
// 初始化插件
} );
错误:元数据被多次保存,性能问题 来源:WordPress 文章元数据 原因:在 save_post 钩子中没有自动保存检查 预防:检查 DOING_AUTOSAVE 常量
add_action( 'save_post', function( $post_id ) {
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
// 安全地保存元数据
} );
错误:AJAX 响应缓慢 来源:https://deliciousbrains.com/comparing-wordpress-rest-api-performance-admin-ajax-php/ 原因:admin-ajax.php 加载了整个 WordPress 核心 预防:对于新项目使用 REST API(快10倍)
// 旧:admin-ajax.php(仍然有效但较慢)
add_action( 'wp_ajax_mypl_action', 'mypl_ajax_handler' );
// 新:REST API(快10倍,推荐)
add_action( 'rest_api_init', function() {
register_rest_route( 'myplugin/v1', '/endpoint', array(
'methods' => 'POST',
'callback' => 'mypl_rest_handler',
'permission_callback' => function() {
return current_user_can( 'edit_posts' );
},
) );
} );
错误:自定义文章类型显示经典编辑器而不是 Gutenberg 块编辑器
来源:WordPress VIP 文档,GitHub Issue #7595
原因:注册自定义文章类型时忘记设置 show_in_rest => true
预防:对于需要块编辑器的 CPT,始终包含 show_in_rest
// ❌ 错误 - 块编辑器将无法工作
register_post_type( 'book', array(
'public' => true,
'supports' => array('editor'),
// 缺少 show_in_rest!
) );
// ✅ 正确
register_post_type( 'book', array(
'public' => true,
'show_in_rest' => true, // 块编辑器必需
'supports' => array('editor'),
) );
关键规则:只有注册了 'show_in_rest' => true 的文章类型才与块编辑器兼容。块编辑器依赖于 WordPress REST API。对于与块编辑器不兼容的文章类型——或者设置了 show_in_rest => false 的文章类型——将加载经典编辑器。
错误:由于表名被引号包围导致 SQL 语法错误,或者硬编码的前缀在不同安装上失效 来源:WordPress 编码标准 Issue #2442 原因:将表名用作占位符会给表名添加引号 预防:表名必须不在 prepare() 的占位符中
// ❌ 错误 - 给表名添加引号
$table = $wpdb->prefix . 'my_table';
$wpdb->get_results( $wpdb->prepare(
"SELECT * FROM %s WHERE id = %d",
$table, $id
) );
// 结果:SELECT * FROM 'wp_my_table' WHERE id = 1
// 失败 - 表名被引号包围
// ❌ 错误 - 硬编码前缀
$wpdb->get_results( $wpdb->prepare(
"SELECT * FROM wp_my_table WHERE id = %d",
$id
) );
// 如果用户更改了表前缀则会失败
// ✅ 正确 - 表名不在 prepare() 中
$table = $wpdb->prefix . 'my_table';
$wpdb->get_results( $wpdb->prepare(
"SELECT * FROM {$table} WHERE id = %d",
$id
) );
// ✅ 正确 - 对内置表使用 wpdb->prefix
$wpdb->get_results( $wpdb->prepare(
"SELECT * FROM {$wpdb->posts} WHERE ID = %d",
$id
) );
错误:随机数失败导致用户体验混乱,或产生虚假的安全感 来源:MalCare: wp_verify_nonce(),Pressidium: 理解随机数 原因:误解随机数的行为和限制 预防:理解随机数的边缘情况,并始终与权限检查结合使用
边缘情况:
$result = wp_verify_nonce( $nonce, 'action' );
// 返回 1:有效,生成于 0-12 小时前
// 返回 2:有效,生成于 12-24 小时前
// 返回 false:无效或已过期
2. 随机数可重用性:WordPress 不跟踪随机数是否已被使用。在 12-24 小时的时间窗口内,它们可以被多次使用。
会话失效:随机数仅在绑定到有效会话时才有效。如果用户注销,他们所有的随机数都会失效,如果他们打开了表单,会导致混乱的用户体验。
缓存问题:当缓存插件提供较旧的随机数时,缓存问题可能导致不匹配。
不能替代授权:
// ❌ 不足 - 仅检查来源,不检查权限
if ( wp_verify_nonce( $_POST['nonce'], 'delete_user' ) ) {
delete_user( $_POST['user_id'] );
}
// ✅ 正确 - 与权限检查结合
if ( wp_verify_nonce( $_POST['nonce'], 'delete_user' ) &&
current_user_can( 'delete_users' ) ) {
delete_user( absint( $_POST['user_id'] ) );
}
关键原则(2025):绝不要依赖随机数进行身份验证或授权。始终假设随机数可能被泄露。使用 current_user_can() 保护您的函数。
错误:钩子回调未收到预期的参数,或以错误的顺序运行 来源:Kinsta: WordPress 钩子训练营 原因:默认只有1个参数,优先级默认为10 预防:在需要时明确指定参数数量和优先级
// ❌ 错误 - 只接收 $post_id
add_action( 'save_post', 'my_save_function' );
function my_save_function( $post_id, $post, $update ) {
// $post 和 $update 是 NULL!
}
// ✅ 正确 - 指定参数数量
add_action( 'save_post', 'my_save_function', 10, 3 );
function my_save_function( $post_id, $post, $update ) {
// 现在所有3个参数都可用
}
// 优先级很重要(数字越小 = 运行越早)
add_action( 'init', 'first_function', 5 ); // 首先运行
add_action( 'init', 'second_function', 10 ); // 默认优先级
add_action( 'init', 'third_function', 15 ); // 最后运行
最佳实践:
do_action( 'mypl_data_processed' ) 而不是 do_action( 'data_processed' )错误:即使刷新了固定链接,单个 CPT 文章仍返回 404 错误 来源:Permalink Manager Pro: URL 冲突 原因:CPT slug 与页面 slug 匹配,造成 URL 冲突 预防:为 CPT 使用不同的 slug 或重命名页面
// ❌ 冲突 - 页面和 CPT 使用相同的 slug
// 页面 URL:example.com/portfolio/
register_post_type( 'portfolio', array(
'rewrite' => array( 'slug' => 'portfolio' ),
) );
// 单个文章 404:example.com/portfolio/my-project/
// ✅ 解决方案 1 - 为 CPT 使用不同的 slug
register_post_type( 'portfolio', array(
'rewrite' => array( 'slug' => 'projects' ),
) );
// 文章:example.com/projects/my-project/
// 页面:example.com/portfolio/
// ✅ 解决方案 2 - 使用分层 slug
register_post_type( 'portfolio', array(
'rewrite' => array( 'slug' => 'work/portfolio' ),
) );
// 文章:example.com/work/portfolio/my-project/
// ✅ 解决方案 3 - 重命名页面 slug
// 将页面从 /portfolio/ 改为 /our-portfolio/
错误:WordPress 6.8 升级后,自定义密码哈希处理中断 来源:WordPress Core Make,GitHub Issue #21022 原因:WordPress 6.8+ 从 phpass 切换到 bcrypt 密码哈希 预防:使用 WordPress 密码函数,不要直接处理哈希
变更内容(WordPress 6.8,2025年4月):
默认密码哈希算法从 phpass 改为 bcrypt
新的哈希前缀:$wp$2y$(SHA-384 预哈希的 bcrypt)
现有密码在下次登录时自动重新哈希
流行的 bcrypt 插件(roots/wp-password-bcrypt)现在变得多余
// ✅ 安全 - 这些函数无需更改即可继续工作 wp_hash_password( $password ); wp_check_password( $password, $hash );
// ⚠️ 需要更新 - 直接处理 phpass 哈希 if ( strpos( $hash, '$P$' ) === 0 ) { // 自定义 phpass 逻辑 - 需要为 bcrypt 更新 }
// ✅ 新 - 检测哈希类型 if ( strpos( $hash, '$wp$2y$' ) === 0 ) { // bcrypt 哈希 (WordPress 6.8+) } elseif ( strpos( $hash, '$P$' ) === 0 ) { // phpass 哈希 (WordPress <6.8) }
需要采取的行动:
错误:"Deprecated: Function WP_Dependencies->add_data() was called with an argument that is deprecated" 来源:WordPress 6.9 文档,WordPress 支持论坛 原因:WordPress 6.9(2025年12月2日)弃用了 WP_Dependencies 对象方法 预防:在 WordPress 6.9 上启用 WP_DEBUG 测试插件,替换已弃用的方法
受影响的插件(已确认):
破坏性变更:WordPress 6.9 移除或修改了几个旧版主题和插件所依赖的已弃用函数,破坏了自定义菜单遍历器、经典小部件、媒体模态框和自定义器功能。
需要采取的行动:
错误:翻译未加载或出现调试通知 来源:WooCommerce 开发者博客,WordPress 6.7 现场指南 原因:WordPress 6.7+ 改变了翻译加载的时间和方式 预防:在 'init' 优先级 10 之后加载翻译,确保文本域与插件 slug 匹配
// ❌ 错误 - 加载过早
add_action( 'init', 'load_plugin_textdomain' );
// ✅ 正确 - 在 'init' 优先级 10 之后加载
add_action( 'init', 'load_plugin_textdomain', 11 );
// 确保文本域与插件 slug 完全匹配
// 插件头部:Text Domain: my-plugin
__( 'Text', 'my-plugin' ); // 必须完全匹配
需要采取的行动:
错误:"The query argument of wpdb::prepare() must have a placeholder" 来源:WordPress $wpdb 文档,SitePoint: 在 WordPress 中使用数据库 原因:使用 prepare() 时没有任何占位符 预防:如果没有动态数据,不要使用 prepare()
// ❌ 错误
$wpdb->prepare( "SELECT * FROM {$wpdb->posts}" );
// 错误:wpdb::prepare() 的查询参数必须有一个占位符
// ✅ 正确 - 如果没有动态数据,不要使用 prepare()
$wpdb->get_results( "SELECT * FROM {$wpdb->posts}" );
// ✅ 正确 - 对于动态数据使用 prepare()
$wpdb->get_results( $wpdb->prepare(
"SELECT * FROM {$wpdb->posts} WHERE ID = %d",
$post_id
) );
**其他 wpdb::prepare()
Last Updated : 2026-01-21 Latest Versions : WordPress 6.9+ (Dec 2, 2025), PHP 8.0+ recommended, PHP 8.5 compatible Dependencies : None (WordPress 5.9+, PHP 7.4+ minimum)
Architecture Patterns : Simple (functions only, <5 functions) | OOP (medium plugins) | PSR-4 (modern/large, recommended 2025+)
Plugin Header (only Plugin Name required):
<?php
/**
* Plugin Name: My Plugin
* Version: 1.0.0
* Requires at least: 5.9
* Requires PHP: 7.4
* Text Domain: my-plugin
*/
if ( ! defined( 'ABSPATH' ) ) exit;
Security Foundation (5 essentials before writing functionality):
// 1. Unique Prefix
define( 'MYPL_VERSION', '1.0.0' );
function mypl_init() { /* code */ }
add_action( 'init', 'mypl_init' );
// 2. ABSPATH Check (every PHP file)
if ( ! defined( 'ABSPATH' ) ) exit;
// 3. Nonces
wp_nonce_field( 'mypl_action', 'mypl_nonce' );
wp_verify_nonce( $_POST['mypl_nonce'], 'mypl_action' );
// 4. Sanitize Input, Escape Output
$clean = sanitize_text_field( $_POST['input'] );
echo esc_html( $output );
// 5. Prepared Statements
global $wpdb;
$wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}table WHERE id = %d", $id ) );
Apply to: functions, classes, constants, options, transients, meta keys. Avoid: wp_, __, _.
function mypl_function() {} // ✅
class MyPL_Class {} // ✅
function init() {} // ❌ Will conflict
// ❌ WRONG - Security hole
if ( is_admin() ) { /* delete data */ }
// ✅ CORRECT
if ( current_user_can( 'manage_options' ) ) { /* delete data */ }
Common: manage_options (Admin), edit_posts (Editor/Author), read (Subscriber)
// Sanitize INPUT
$name = sanitize_text_field( $_POST['name'] );
$email = sanitize_email( $_POST['email'] );
$html = wp_kses_post( $_POST['content'] ); // Allow safe HTML
$ids = array_map( 'absint', $_POST['ids'] );
// Validate LOGIC
if ( ! is_email( $email ) ) wp_die( 'Invalid' );
// Escape OUTPUT
echo esc_html( $name );
echo '<a href="' . esc_url( $url ) . '">';
echo '<div class="' . esc_attr( $class ) . '">';
// Form
<?php wp_nonce_field( 'mypl_action', 'mypl_nonce' ); ?>
if ( ! wp_verify_nonce( $_POST['mypl_nonce'], 'mypl_action' ) ) wp_die( 'Failed' );
// AJAX
check_ajax_referer( 'mypl-ajax-nonce', 'nonce' );
wp_localize_script( 'mypl-script', 'mypl_ajax_object', array(
'ajaxurl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'mypl-ajax-nonce' ),
) );
// ❌ SQL Injection
$wpdb->get_results( "SELECT * FROM table WHERE id = {$_GET['id']}" );
// ✅ Prepared (%s=String, %d=Integer, %f=Float)
$wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}table WHERE id = %d", $_GET['id'] ) );
// LIKE Queries
$search = '%' . $wpdb->esc_like( $term ) . '%';
$wpdb->get_results( $wpdb->prepare( "... WHERE title LIKE %s", $search ) );
✅ Use unique prefix (4-5 chars) for all global code (functions, classes, options, transients) ✅ Add ABSPATH check to every PHP file: if ( ! defined( 'ABSPATH' ) ) exit; ✅ Check capabilities (current_user_can()) not just is_admin() ✅ Verify nonces for all forms and AJAX requests ✅ Use $wpdb- >prepare() for all database queries with user input ✅ Sanitize input with sanitize_*() functions before saving ✅ Escape output with esc_*() functions before displaying ✅ Flush rewrite rules on activation when registering custom post types ✅ Use uninstall.php for permanent cleanup (not deactivation hook) ✅ Follow WordPress Coding Standards (tabs for indentation, Yoda conditions)
❌ Never use extract() - Creates security vulnerabilities ❌ Never trust $_POST/$_GET without sanitization ❌ Never concatenate user input into SQL - Always use prepare() ❌ Never useis_admin() alone for permission checks ❌ Never output unsanitized data - Always escape ❌ Never use generic function/class names - Always prefix ❌ Never use short PHP tags <? or <?= - Use <?php only ❌ Never delete user data on deactivation - Only on uninstall ❌ Never register uninstall hook repeatedly - Only once on activation ❌ Never useregister_uninstall_hook() in main flow - Use uninstall.php instead
This skill prevents 29 documented issues:
Error : Database compromised via unescaped user input Source : https://patchstack.com/articles/sql-injection/ (15% of all vulnerabilities) Why It Happens : Direct concatenation of user input into SQL queries Prevention : Always use $wpdb->prepare() with placeholders
// VULNERABLE
$wpdb->query( "DELETE FROM {$wpdb->prefix}table WHERE id = {$_GET['id']}" );
// SECURE
$wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}table WHERE id = %d", $_GET['id'] ) );
Error : Malicious JavaScript executed in user browsers Source : https://patchstack.com (35% of all vulnerabilities) Why It Happens : Outputting unsanitized user data to HTML Prevention : Always escape output with context-appropriate function
// VULNERABLE
echo $_POST['name'];
echo '<div class="' . $_POST['class'] . '">';
// SECURE
echo esc_html( $_POST['name'] );
echo '<div class="' . esc_attr( $_POST['class'] ) . '">';
Error : Unauthorized actions performed on behalf of users Source : https://blog.nintechnet.com/25-wordpress-plugins-vulnerable-to-csrf-attacks/ Why It Happens : No verification that requests originated from your site Prevention : Use nonces with wp_nonce_field() and wp_verify_nonce()
// VULNERABLE
if ( $_POST['action'] == 'delete' ) {
delete_user( $_POST['user_id'] );
}
// SECURE
if ( ! wp_verify_nonce( $_POST['nonce'], 'mypl_delete_user' ) ) {
wp_die( 'Security check failed' );
}
delete_user( absint( $_POST['user_id'] ) );
Error : Regular users can access admin functions Source : WordPress Security Review Guidelines Why It Happens : Using is_admin() instead of current_user_can() Prevention : Always check capabilities, not just admin context
// VULNERABLE
if ( is_admin() ) {
// Any logged-in user can trigger this
}
// SECURE
if ( current_user_can( 'manage_options' ) ) {
// Only administrators can trigger this
}
Error : PHP files executed outside WordPress context Source : WordPress Plugin Handbook Why It Happens : No ABSPATH check at top of file Prevention : Add ABSPATH check to every PHP file
// Add to top of EVERY PHP file
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
Error : Functions/classes conflict with other plugins Source : WordPress Coding Standards Why It Happens : Generic names without unique prefix Prevention : Use 4-5 character prefix on ALL global code
// CAUSES CONFLICTS
function init() {}
class Settings {}
add_option( 'api_key', $value );
// SAFE
function mypl_init() {}
class MyPL_Settings {}
add_option( 'mypl_api_key', $value );
Error : Custom post types return 404 errors, or database overload from repeated flushing Source : WordPress Plugin Handbook, Permalink Manager Pro Why It Happens : Forgot to flush rewrite rules after registering CPT, OR calling flush on every page load Prevention : Flush ONLY on activation/deactivation, NEVER on every page load
// ✅ CORRECT - Only flush on activation
function mypl_activate() {
mypl_register_cpt();
flush_rewrite_rules();
}
register_activation_hook( __FILE__, 'mypl_activate' );
function mypl_deactivate() {
flush_rewrite_rules();
}
register_deactivation_hook( __FILE__, 'mypl_deactivate' );
// ❌ WRONG - Causes database overload on EVERY page load
add_action( 'init', 'mypl_register_cpt' );
add_action( 'init', 'flush_rewrite_rules' ); // BAD! Performance killer!
// ❌ WRONG - In functions.php
function mypl_register_cpt() {
register_post_type( 'book', ... );
flush_rewrite_rules(); // BAD! Runs every time
}
User-Facing Fix : If CPT shows 404, manually flush by going to Settings → Permalinks → Save Changes.
Error : Database accumulates expired transients Source : WordPress Transients API Documentation Why It Happens : No cleanup on uninstall Prevention : Delete transients in uninstall.php
// uninstall.php
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit;
}
global $wpdb;
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_mypl_%'" );
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_mypl_%'" );
Error : Performance degraded by unnecessary asset loading Source : WordPress Performance Best Practices Why It Happens : Enqueuing scripts/styles without conditional checks Prevention : Only load assets where needed
// BAD - Loads on every page
add_action( 'wp_enqueue_scripts', function() {
wp_enqueue_script( 'mypl-script', $url );
} );
// GOOD - Only loads on specific page
add_action( 'wp_enqueue_scripts', function() {
if ( is_page( 'my-page' ) ) {
wp_enqueue_script( 'mypl-script', $url, array( 'jquery' ), '1.0', true );
}
} );
Error : Malicious data stored in database Source : WordPress Data Validation Why It Happens : Saving $_POST data without sanitization Prevention : Always sanitize before saving
// VULNERABLE
update_option( 'mypl_setting', $_POST['value'] );
// SECURE
update_option( 'mypl_setting', sanitize_text_field( $_POST['value'] ) );
Error : SQL syntax errors or injection vulnerabilities Source : WordPress $wpdb Documentation Why It Happens : LIKE wildcards not escaped properly Prevention : Use $wpdb->esc_like()
// WRONG
$search = '%' . $term . '%';
// CORRECT
$search = '%' . $wpdb->esc_like( $term ) . '%';
$results = $wpdb->get_results( $wpdb->prepare( "... WHERE title LIKE %s", $search ) );
Error : Variable collision and security vulnerabilities Source : WordPress Coding Standards Why It Happens : extract() creates variables from array keys Prevention : Never use extract(), access array elements directly
// DANGEROUS
extract( $_POST );
// Now $any_array_key becomes a variable
// SAFE
$name = isset( $_POST['name'] ) ? sanitize_text_field( $_POST['name'] ) : '';
Error : Endpoints accessible to everyone, allowing unauthorized access or privilege escalation Source : WordPress REST API Handbook, Patchstack CVE Database Why It Happens : No permission_callback specified, or missing show_in_index => false for sensitive endpoints Prevention : Always add permission_callback AND hide sensitive endpoints from REST index
Real 2025-2026 Vulnerabilities :
All in One SEO (3M+ sites) : Missing permission check allowed contributor-level users to view global AI access token
AI Engine Plugin (CVE-2025-11749, CVSS 9.8 Critical) : Failed to set show_in_index => false, exposed bearer token in /wp-json/ index, full admin privileges granted to unauthenticated attackers
SureTriggers : Insufficient authorization checks exploited within 4 hours of disclosure
Worker for Elementor (CVE-2025-66144) : Subscriber-level privileges could invoke restricted features
// ❌ VULNERABLE - Missing permission_callback (WordPress 5.5+ requires it!) register_rest_route( 'myplugin/v1', '/data', array( 'methods' => 'GET', 'callback' => 'my_callback', ) );
// ✅ SECURE - Basic protection register_rest_route( 'myplugin/v1', '/data', array( 'methods' => 'GET', 'callback' => 'my_callback', 'permission_callback' => function() { return current_user_can( 'edit_posts' ); }, ) );
// ✅ SECURE - Hide sensitive endpoints from REST index register_rest_route( 'myplugin/v1', '/admin', array( 'methods' => 'POST', 'callback' => 'my_admin_callback', 'permission_callback' => function() { return current_user_can( 'manage_options' ); }, 'show_in_index' => false, // Don't expose in /wp-json/ ) );
2025-2026 Statistics : 64,782 total vulnerabilities tracked, 333 new in one week, 236 remained unpatched. REST API auth issues represent significant percentage.
Error : Option written on every page load Source : WordPress Plugin Handbook Why It Happens : register_uninstall_hook() called in main flow Prevention : Use uninstall.php file instead
// BAD - Runs on every page load
register_uninstall_hook( __FILE__, 'mypl_uninstall' );
// GOOD - Use uninstall.php file (preferred method)
// Create uninstall.php in plugin root
Error : Users lose data when temporarily disabling plugin Source : WordPress Plugin Development Best Practices Why It Happens : Confusion about deactivation vs uninstall Prevention : Only delete data in uninstall.php, never on deactivation
// WRONG - Deletes user data on deactivation
register_deactivation_hook( __FILE__, function() {
delete_option( 'mypl_user_settings' );
} );
// CORRECT - Only clear temporary data on deactivation
register_deactivation_hook( __FILE__, function() {
delete_transient( 'mypl_cache' );
} );
// CORRECT - Delete all data in uninstall.php
Error : Plugin breaks on WordPress updates Source : WordPress Deprecated Functions List Why It Happens : Using functions removed in newer WordPress versions Prevention : Enable WP_DEBUG during development
// In wp-config.php (development only)
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', false );
Error : Translations don't load Source : WordPress Internationalization Why It Happens : Text domain doesn't match plugin slug Prevention : Use exact plugin slug everywhere
// Plugin header
// Text Domain: my-plugin
// In code - MUST MATCH EXACTLY
__( 'Text', 'my-plugin' );
_e( 'Text', 'my-plugin' );
Error : Fatal error when required plugin is inactive Source : WordPress Plugin Dependencies Why It Happens : No check for required plugins Prevention : Check for dependencies on plugins_loaded
add_action( 'plugins_loaded', function() {
if ( ! class_exists( 'WooCommerce' ) ) {
add_action( 'admin_notices', function() {
echo '<div class="error"><p>My Plugin requires WooCommerce.</p></div>';
} );
return;
}
// Initialize plugin
} );
Error : Meta saved multiple times, performance issues Source : WordPress Post Meta Why It Happens : No autosave check in save_post hook Prevention : Check for DOING_AUTOSAVE constant
add_action( 'save_post', function( $post_id ) {
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
// Safe to save meta
} );
Error : Slow AJAX responses Source : https://deliciousbrains.com/comparing-wordpress-rest-api-performance-admin-ajax-php/ Why It Happens : admin-ajax.php loads entire WordPress core Prevention : Use REST API for new projects (10x faster)
// OLD: admin-ajax.php (still works but slower)
add_action( 'wp_ajax_mypl_action', 'mypl_ajax_handler' );
// NEW: REST API (10x faster, recommended)
add_action( 'rest_api_init', function() {
register_rest_route( 'myplugin/v1', '/endpoint', array(
'methods' => 'POST',
'callback' => 'mypl_rest_handler',
'permission_callback' => function() {
return current_user_can( 'edit_posts' );
},
) );
} );
Error : Custom post types show classic editor instead of Gutenberg block editor Source : WordPress VIP Documentation, GitHub Issue #7595 Why It Happens : Forgot to set show_in_rest => true when registering custom post type Prevention : Always include show_in_rest for CPTs that need block editor
// ❌ WRONG - Block editor won't work
register_post_type( 'book', array(
'public' => true,
'supports' => array('editor'),
// Missing show_in_rest!
) );
// ✅ CORRECT
register_post_type( 'book', array(
'public' => true,
'show_in_rest' => true, // Required for block editor
'supports' => array('editor'),
) );
Critical Rule : Only post types registered with 'show_in_rest' => true are compatible with the block editor. The block editor is dependent on the WordPress REST API. For post types that are incompatible with the block editor—or have show_in_rest => false—the classic editor will load instead.
Error : SQL syntax error from quoted table names, or hardcoded prefix breaks on different installations Source : WordPress Coding Standards Issue #2442 Why It Happens : Using table names as placeholders adds quotes around the table name Prevention : Table names must NOT be in prepare() placeholders
// ❌ WRONG - Adds quotes around table name
$table = $wpdb->prefix . 'my_table';
$wpdb->get_results( $wpdb->prepare(
"SELECT * FROM %s WHERE id = %d",
$table, $id
) );
// Result: SELECT * FROM 'wp_my_table' WHERE id = 1
// FAILS - table name is quoted
// ❌ WRONG - Hardcoded prefix
$wpdb->get_results( $wpdb->prepare(
"SELECT * FROM wp_my_table WHERE id = %d",
$id
) );
// FAILS if user changed table prefix
// ✅ CORRECT - Table name NOT in prepare()
$table = $wpdb->prefix . 'my_table';
$wpdb->get_results( $wpdb->prepare(
"SELECT * FROM {$table} WHERE id = %d",
$id
) );
// ✅ CORRECT - Using wpdb->prefix for built-in tables
$wpdb->get_results( $wpdb->prepare(
"SELECT * FROM {$wpdb->posts} WHERE ID = %d",
$id
) );
Error : Confusing user experience from nonce failures, or false sense of security Source : MalCare: wp_verify_nonce(), Pressidium: Understanding Nonces Why It Happens : Misunderstanding nonce behavior and limitations Prevention : Understand nonce edge cases and always combine with capability checks
Edge Cases :
$result = wp_verify_nonce( $nonce, 'action' );
// Returns 1: Valid, generated 0-12 hours ago
// Returns 2: Valid, generated 12-24 hours ago
// Returns false: Invalid or expired
2. Nonce Reusability : WordPress doesn't track if a nonce has been used. They can be used multiple times within the 12-24 hour window.
Session Invalidation : A nonce is only valid when tied to a valid session. If a user logs out, all their nonces become invalid, causing confusing UX if they had a form open.
Caching Problems : Cache issues can cause mismatches when caching plugins serve an older nonce.
NOT a Substitute for Authorization :
// ❌ INSUFFICIENT - Only checks origin, not permission
if ( wp_verify_nonce( $_POST['nonce'], 'delete_user' ) ) {
delete_user( $_POST['user_id'] );
}
// ✅ CORRECT - Combine with capability check
if ( wp_verify_nonce( $_POST['nonce'], 'delete_user' ) &&
current_user_can( 'delete_users' ) ) {
delete_user( absint( $_POST['user_id'] ) );
}
Key Principle (2025) : Nonces should never be relied on for authentication or authorization. Always assume nonces can be compromised. Protect your functions using current_user_can().
Error : Hook callback doesn't receive expected arguments, or runs in wrong order Source : Kinsta: WordPress Hooks Bootcamp Why It Happens : Default is only 1 argument, priority defaults to 10 Prevention : Specify argument count and priority explicitly when needed
// ❌ WRONG - Only receives $post_id
add_action( 'save_post', 'my_save_function' );
function my_save_function( $post_id, $post, $update ) {
// $post and $update are NULL!
}
// ✅ CORRECT - Specify argument count
add_action( 'save_post', 'my_save_function', 10, 3 );
function my_save_function( $post_id, $post, $update ) {
// Now all 3 arguments are available
}
// Priority matters (lower number = runs earlier)
add_action( 'init', 'first_function', 5 ); // Runs first
add_action( 'init', 'second_function', 10 ); // Default priority
add_action( 'init', 'third_function', 15 ); // Runs last
Best Practices :
do_action( 'mypl_data_processed' ) not do_action( 'data_processed' )Error : Individual CPT posts return 404 errors despite permalinks flushed Source : Permalink Manager Pro: URL Conflicts Why It Happens : CPT slug matches a page slug, creating URL conflict Prevention : Use different slug for CPT or rename the page
// ❌ CONFLICT - Page and CPT use same slug
// Page URL: example.com/portfolio/
register_post_type( 'portfolio', array(
'rewrite' => array( 'slug' => 'portfolio' ),
) );
// Individual posts 404: example.com/portfolio/my-project/
// ✅ SOLUTION 1 - Use different slug for CPT
register_post_type( 'portfolio', array(
'rewrite' => array( 'slug' => 'projects' ),
) );
// Posts: example.com/projects/my-project/
// Page: example.com/portfolio/
// ✅ SOLUTION 2 - Use hierarchical slug
register_post_type( 'portfolio', array(
'rewrite' => array( 'slug' => 'work/portfolio' ),
) );
// Posts: example.com/work/portfolio/my-project/
// ✅ SOLUTION 3 - Rename the page slug
// Change page from /portfolio/ to /our-portfolio/
Error : Custom password hash handling breaks after WordPress 6.8 upgrade Source : WordPress Core Make, GitHub Issue #21022 Why It Happens : WordPress 6.8+ switched from phpass to bcrypt password hashing Prevention : Use WordPress password functions, don't handle hashes directly
What Changed (WordPress 6.8, April 2025):
Default password hashing algorithm changed from phpass to bcrypt
New hash prefix: $wp$2y$ (SHA-384 pre-hashed bcrypt)
Existing passwords automatically rehashed on next login
Popular bcrypt plugins (roots/wp-password-bcrypt) now redundant
// ✅ SAFE - These functions continue to work without changes wp_hash_password( $password ); wp_check_password( $password, $hash );
// ⚠️ NEEDS UPDATE - Direct phpass hash handling if ( strpos( $hash, '$P$' ) === 0 ) { // Custom phpass logic - needs update for bcrypt }
// ✅ NEW - Detect hash type if ( strpos( $hash, '$wp$2y$' ) === 0 ) { // bcrypt hash (WordPress 6.8+) } elseif ( strpos( $hash, '$P$' ) === 0 ) { // phpass hash (WordPress <6.8) }
Action Required :
Error : "Deprecated: Function WP_Dependencies->add_data() was called with an argument that is deprecated" Source : WordPress 6.9 Documentation, WordPress Support Forum Why It Happens : WordPress 6.9 (Dec 2, 2025) deprecated WP_Dependencies object methods Prevention : Test plugins with WP_DEBUG enabled on WordPress 6.9, replace deprecated methods
Affected Plugins (confirmed):
Breaking Changes : WordPress 6.9 removed or modified several deprecated functions that older themes and plugins relied on, breaking custom menu walkers, classic widgets, media modals, and customizer features.
Action Required :
Error : Translations don't load or debug notices appear Source : WooCommerce Developer Blog, WordPress 6.7 Field Guide Why It Happens : WordPress 6.7+ changed when/how translations load Prevention : Load translations after 'init' priority 10, ensure text domain matches plugin slug
// ❌ WRONG - Loading too early
add_action( 'init', 'load_plugin_textdomain' );
// ✅ CORRECT - Load after 'init' priority 10
add_action( 'init', 'load_plugin_textdomain', 11 );
// Ensure text domain matches plugin slug EXACTLY
// Plugin header: Text Domain: my-plugin
__( 'Text', 'my-plugin' ); // Must match exactly
Action Required :
Error : "The query argument of wpdb::prepare() must have a placeholder" Source : WordPress $wpdb Documentation, SitePoint: Working with Databases Why It Happens : Using prepare() without any placeholders Prevention : Don't use prepare() if no dynamic data
// ❌ WRONG
$wpdb->prepare( "SELECT * FROM {$wpdb->posts}" );
// Error: The query argument of wpdb::prepare() must have a placeholder
// ✅ CORRECT - Don't use prepare() if no dynamic data
$wpdb->get_results( "SELECT * FROM {$wpdb->posts}" );
// ✅ CORRECT - Use prepare() for dynamic data
$wpdb->get_results( $wpdb->prepare(
"SELECT * FROM {$wpdb->posts} WHERE ID = %d",
$post_id
) );
Additional wpdb::prepare() Mistakes :
// ❌ WRONG
$wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE post_title LIKE '%test%'" );
// ✅ CORRECT
$search = '%' . $wpdb->esc_like( $term ) . '%';
$wpdb->get_results( $wpdb->prepare(
"SELECT * FROM {$wpdb->posts} WHERE post_title LIKE %s",
$search
) );
2. Mixing Argument Formats :
// ❌ WRONG - Can't mix individual args and array
$wpdb->prepare( "... WHERE id = %d AND name = %s", $id, array( $name ) );
// ✅ CORRECT - Pick one format
$wpdb->prepare( "... WHERE id = %d AND name = %s", $id, $name );
// OR
$wpdb->prepare( "... WHERE id = %d AND name = %s", array( $id, $name ) );
Small plugins (<5 functions):
function mypl_init() { /* code */ }
add_action( 'init', 'mypl_init' );
Medium plugins:
class MyPL_Plugin {
private static $instance = null;
public static function get_instance() {
if ( null === self::$instance ) self::$instance = new self();
return self::$instance;
}
private function __construct() {
add_action( 'init', array( $this, 'init' ) );
}
}
MyPL_Plugin::get_instance();
Large/team plugins:
my-plugin/
├── my-plugin.php
├── composer.json → "psr-4": { "MyPlugin\\": "src/" }
└── src/Admin.php
// my-plugin.php
require_once __DIR__ . '/vendor/autoload.php';
use MyPlugin\Admin;
new Admin();
Custom Post Types (CRITICAL: Flush rewrite rules on activation, show_in_rest for block editor):
// show_in_rest => true REQUIRED for Gutenberg block editor
register_post_type( 'book', array(
'public' => true,
'show_in_rest' => true, // Without this, block editor won't work!
'supports' => array( 'editor', 'title' ),
) );
register_activation_hook( __FILE__, function() {
mypl_register_cpt();
flush_rewrite_rules(); // NEVER call on every page load
} );
Custom Taxonomies :
register_taxonomy( 'genre', 'book', array( 'hierarchical' => true, 'show_in_rest' => true ) );
Meta Boxes :
add_meta_box( 'book_details', 'Book Details', 'mypl_meta_box_html', 'book' );
// Save: Check nonce, DOING_AUTOSAVE, current_user_can('edit_post')
update_post_meta( $post_id, '_book_isbn', sanitize_text_field( $_POST['book_isbn'] ) );
Settings API :
register_setting( 'mypl_options', 'mypl_api_key', array( 'sanitize_callback' => 'sanitize_text_field' ) );
add_settings_section( 'mypl_section', 'API Settings', 'callback', 'my-plugin' );
add_settings_field( 'mypl_api_key', 'API Key', 'field_callback', 'my-plugin', 'mypl_section' );
REST API (10x faster than admin-ajax.php):
register_rest_route( 'myplugin/v1', '/data', array(
'methods' => 'POST',
'callback' => 'mypl_rest_callback',
'permission_callback' => fn() => current_user_can( 'edit_posts' ),
) );
AJAX (Legacy, use REST API for new projects):
add_action( 'wp_ajax_mypl_action', 'mypl_ajax_handler' );
check_ajax_referer( 'mypl-ajax-nonce', 'nonce' );
wp_send_json_success( array( 'message' => 'Success' ) );
Custom Tables :
global $wpdb;
$sql = "CREATE TABLE {$wpdb->prefix}mypl_data (id bigint AUTO_INCREMENT PRIMARY KEY, ...)";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
Transients (Caching):
$data = get_transient( 'mypl_data' );
if ( false === $data ) {
$data = expensive_operation();
set_transient( 'mypl_data', $data, 12 * HOUR_IN_SECONDS );
}
Templates : plugin-simple/, plugin-oop/, plugin-psr4/, examples/meta-box.php, examples/settings-page.php, examples/custom-post-type.php, examples/rest-endpoint.php, examples/ajax-handler.php
Scripts : scaffold-plugin.sh, check-security.sh, validate-headers.sh
References : security-checklist.md, hooks-reference.md, sanitization-guide.md, wpdb-patterns.md, common-errors.md
i18n (Internationalization):
load_plugin_textdomain( 'my-plugin', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );
__( 'Text', 'my-plugin' ); // Return translated
_e( 'Text', 'my-plugin' ); // Echo translated
esc_html__( 'Text', 'my-plugin' ); // Translate + escape
WP-CLI :
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::add_command( 'mypl', 'MyPL_CLI_Command' );
}
Cron Events :
register_activation_hook( __FILE__, fn() => wp_schedule_event( time(), 'daily', 'mypl_daily_task' ) );
register_deactivation_hook( __FILE__, fn() => wp_clear_scheduled_hook( 'mypl_daily_task' ) );
add_action( 'mypl_daily_task', 'mypl_do_daily_task' );
Plugin Dependencies :
if ( ! class_exists( 'WooCommerce' ) ) {
deactivate_plugins( plugin_basename( __FILE__ ) );
add_action( 'admin_notices', fn() => echo '<div class="error"><p>Requires WooCommerce</p></div>' );
}
GitHub Auto-Updates (Plugin Update Checker by YahnisElsts):
// 1. Install: git submodule add https://github.com/YahnisElsts/plugin-update-checker.git
// 2. Add to main plugin file
require plugin_dir_path( __FILE__ ) . 'plugin-update-checker/plugin-update-checker.php';
use YahnisElsts\PluginUpdateChecker\v5\PucFactory;
$updateChecker = PucFactory::buildUpdateChecker(
'https://github.com/yourusername/your-plugin/',
__FILE__,
'your-plugin-slug'
);
$updateChecker->getVcsApi()->enableReleaseAssets(); // Use GitHub Releases
// Private repos: Define token in wp-config.php
if ( defined( 'YOUR_PLUGIN_GITHUB_TOKEN' ) ) {
$updateChecker->setAuthentication( YOUR_PLUGIN_GITHUB_TOKEN );
}
Deployment :
git tag 1.0.1 && git push origin main && git push origin 1.0.1
# Create GitHub Release with ZIP (exclude .git, tests)
Alternatives : Git Updater (no coding), Custom Update Server (full control), Freemius (commercial)
Security : Use HTTPS, never hardcode tokens, validate licenses, rate limit update checks
CRITICAL : ZIP must contain plugin folder: plugin.zip/my-plugin/my-plugin.php
Resources : See references/github-auto-updates.md, examples/github-updater.php
Required :
Optional :
Fatal Error : Enable WP_DEBUG, check wp-content/debug.log, verify prefixed names, check dependencies
404 on CPT : Flush rewrite rules via Settings → Permalinks → Save
Nonce Fails : Check nonce name/action match, verify not expired (24h default)
AJAX Returns 0/-1 : Verify action name matches wp_ajax_{action}, check nonce sent/verified
HTML Stripped : Use wp_kses_post() not sanitize_text_field() for safe HTML
Query Fails : Use $wpdb->prepare(), check $wpdb->prefix, verify syntax
Use this checklist to verify your plugin:
Questions? Issues?
references/common-errors.md for extended troubleshootingLast verified : 2026-01-21 | Skill version : 2.0.0 | Changes : Added 9 new issues from WordPress 6.7-6.9 research (bcrypt migration, WP_Dependencies deprecation, translation loading, REST API CVEs 2025-2026, wpdb::prepare() edge cases, nonce limitations, hook gotchas, CPT URL conflicts). Updated from 20 to 29 documented errors prevented. Error count: 20 → 29. Version: 1.x → 2.0.0 (major version due to significant WordPress version-specific content additions).
Weekly Installs
560
Repository
GitHub Stars
643
First Seen
Jan 20, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
claude-code414
opencode402
gemini-cli391
codex361
github-copilot296
antigravity293
React 组合模式指南:Vercel 组件架构最佳实践,提升代码可维护性
103,800 周安装
交互式作品集设计指南:30秒吸引招聘者,提升作品集转化率与个人品牌
530 周安装
每日销售简报AI工具 - 自动生成优先级行动计划,整合日历、CRM和邮件数据
531 周安装
生产排程实战指南:离散制造工厂的有限产能排程、换线优化与瓶颈管理
531 周安装
Angular 21 最佳实践指南:TypeScript、Signals、组件与性能优化
531 周安装
find-skills:AI代理技能搜索与安装工具,快速扩展AI助手能力
531 周安装
Spring Boot 测试模式指南:JUnit 5、Mockito、Testcontainers与切片测试
533 周安装