从零开始搭一个 WordPress 插件:我的目录结构、入口文件与本地调试环境踩坑实录
刚开始写插件那会儿,我把所有代码塞进一个 zsens-admin.php,前端后端、AJAX、短代码全挤在一起,调试时改一行崩三处。后来拆过三个商业插件的源码,才理解目录结构不是"规范强迫症",是降低调试认知负担的刚需。这篇记录我现在的脚手架,以及本地环境怎么配才能"秒级复现问题"。
一、我的目录结构:按"加载阶段"分层,不是按"功能类型"
很多教程教你按 admin/、public/、includes/ 分,我早期也这么干。但实际开发中,你更常问的是"这段代码在哪个生命周期跑",而不是"这是前端还是后端"。所以我改成了按加载时序组织:
zsens-admin/
├── zsens-admin.php # 入口:只做"是否该加载"的判断
├── bootstrap/
│ ├── loader.php # 类自动加载器(PSR-4 简化版)
│ └── dependencies.php # 检查 PHP/WP 版本、依赖插件
├── early/
│ ├── constants.php # 定义常量(必须在插件加载前可用)
│ └── mu-fallback.php # 若需 MU 插件模式时的兼容入口
├── hooks/
│ ├── register-activation.php
│ ├── register-deactivation.php
│ └── register-uninstall.php
├── core/
│ ├── Plugin.php # 主类: orchestration,不干事
│ ├── Admin.php # 后台相关钩子聚合
│ ├── Frontend.php # 前台相关钩子聚合
│ └── Ajax.php # AJAX 端点注册(注意:注册≠处理)
├── modules/ # 实际功能模块,按需懒加载
│ ├── user-levels/
│ │ ├── class-user-levels.php
│ │ └── assets/ # 模块级资源,不堆到全局
│ └── community-bridge/
│ └── ...
├── vendor/ # Composer 依赖(提交 lock,不提交 vendor 到 SVN)
└── tests/
├── phpunit/
│ └── bootstrap.php # 加载 WP 测试框架
└── e2e/
└── playwright.config.js
关键变化:core/ 里的类只负责"什么时候挂载什么",具体实现下沉到 modules/。这样调试时,我先看 Plugin.php 的 run() 方法就能定位问题阶段,不用在 2000 行代码里翻。
二、入口文件:越薄越好,但要防"直接访问"和"重复定义"两种死法
我的 zsens-admin.php 现在长这样,注释比代码多:
<?php
/**
* Plugin Name: Zsens Admin
* Version: 2.1.0
* Requires PHP: 7.4
*/
// 死法一:直接访问 PHP 文件
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// 死法二:被其他插件以不同路径重复加载
if ( defined( 'ZSENS_ADMIN_VERSION' ) ) {
return; // 静默退出,不抛异常(避免 WSOD)
}
define( 'ZSENS_ADMIN_VERSION', '2.1.0' );
define( 'ZSENS_ADMIN_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'ZSENS_ADMIN_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
// 版本门槛:在自动加载前就要拦掉
if ( version_compare( PHP_VERSION, '7.4', '<' ) ) {
add_action( 'admin_notices', function () {
echo '<div class="error"><p>Zsens Admin 需要 PHP 7.4+</p></div>';
} );
return;
}
// 自动加载器:不依赖 Composer 也能工作
require_once ZSENS_ADMIN_PLUGIN_DIR . 'bootstrap/loader.php';
// 依赖检查(比如是否装了 WooCommerce)
require_once ZSENS_ADMIN_PLUGIN_DIR . 'bootstrap/dependencies.php';
if ( ! Zsens\Dependencies::check() ) {
return; // dependencies.php 里自己处理通知
}
// 终于,启动
$plugin = new Zsens\Core\Plugin();
$plugin->run();
注意 defined( 'ZSENS_ADMIN_VERSION' ) 这个 guard。我踩过的坑:某客户用 require_once 在 functions.php 里又加载了一次我的插件,导致类重复声明直接 500。现在先检查常量,重复加载时优雅退出。
三、本地调试环境:我用 Docker Compose 但不用现成镜像
试过 LocalWP、DevKinsta、各种一键脚本,最后回归手写 docker-compose.yml。不是折腾,是需要精确复现客户环境:PHP 7.4 vs 8.1、MySQL 5.7 vs 8.0、是否开 OPCache,这些组合问题只在特定版本出现。
我的 docker-compose.yml 核心结构:
version: '3.8'
services:
wp:
build:
context: ./docker/php
args:
PHP_VERSION: ${PHP_VERSION:-8.1} # .env 里切版本
volumes:
- ./:/var/www/html/wp-content/plugins/zsens-admin:delegated
- wp-core:/var/www/html # WP 核心分离,不污染项目
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/99-custom.ini:ro
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DEBUG: 1 # 强制 WP_DEBUG = true
# 我的自定义:自动安装测试数据
ZSENS_INSTALL_FIXTURES: 1
db:
image: mysql:${MYSQL_VERSION:-8.0}
# MySQL 8.0 默认 caching_sha2_password,旧 PHP 连不上
command: --default-authentication-plugin=mysql_native_password
volumes:
- db-data:/var/lib/mysql
- ./docker/mysql/init:/docker-entrypoint-initdb.d:ro
# 关键:单独的 phpMyAdmin 不如 Adminer,但我要的是...
mailhog:
image: mailhog/mailhog
# 拦截所有 wp_mail(),调试邮件模板不用真发
volumes:
wp-core:
db-data:
几个刻意的设计:
- WP 核心用命名卷
wp-core:项目目录只挂载插件本身,wp-admin、wp-includes不在 git 里,但容器销毁后核心还在,重启秒开。 delegated挂载标志:macOS 上 Docker 文件同步的救星,代码变更延迟从 3 秒降到可感知即时。- MailHog 不是摆设:插件有邮件通知功能时,不用配置 SMTP,所有邮件进
localhost:8025的 Web UI。
四、调试技巧:让错误信息"砸到脸上"
WP_DEBUG 只是起点。我的 docker/php/php.ini 里还有这些:
display_errors = On
error_reporting = E_ALL
log_errors = On
error_log = /var/log/php/errors.log
; Xdebug 3 配置,只开开发环境
zend_extension=xdebug.so
xdebug.mode=debug,develop
xdebug.start_with_request=trigger # 不是自动启动,用浏览器插件触发
xdebug.client_host=host.docker.internal
xdebug.log=/var/log/xdebug.log
重点在 xdebug.start_with_request=trigger。以前开自动模式,每个请求都进调试,Docker 性能暴跌。现在装个 Xdebug Helper 浏览器插件,点一下才连 IDE,平时零开销。
另外,我在插件里埋了个开发模式探针,只在 WP_DEBUG && current_user_can('manage_options') 时输出:
add_action( 'shutdown', function () {
if ( ! defined( 'ZSENS_ADMIN_DEBUG_PANEL' ) || ! ZSENS_ADMIN_DEBUG_PANEL ) {
return;
}
$hooks = $GLOBALS['wp_filter'];
// 输出当前请求挂载的钩子、执行时间、内存峰值