从"Hello World"到能下断点:我搭第一个插件目录时走过的结构弯路
刚开始写插件那会儿,我把所有东西塞进一个文件,my-plugin.php 里既有头部声明又有业务逻辑还有样式输出,本地调试全靠 var_dump 刷页面。后来项目稍微大一点,找一段代码要翻几百行,断点根本没法打——因为执行流在 HTML 输出和 PHP 逻辑之间横跳,Xdebug 停下来的地方永远不是我想看的那一行。
这篇记录我后来固定下来的目录骨架,以及怎么让本地环境真正"可调试"。
一、目录结构:从"一坨"到"有层有级"
我现在新开插件必定先建这几个目录,哪怕第一个版本只有两百行代码:
my-plugin/
├── my-plugin.php # 唯一入口,只做"挂号"和"分流"
├── bootstrap.php # 容器、常量、自动加载注册
├── src/
│ ├── Admin/ # 后台相关:页面、Ajax、菜单注册
│ ├── Public/ # 前台相关:短码、小工具、脚本
│ ├── Core/ # 跨层通用:数据库、钩子调度器、工具函数
│ └── Contracts/ # 接口定义,解耦用
├── assets/
│ ├── css/
│ ├── js/
│ └── images/
├── languages/ # 从第一天就留好位置
├── tests/ # 单元测试,后面会说怎么接进本地
└── vendor/ # composer 依赖(如果有)
关键约定:入口文件里不写任何业务逻辑。我的 my-plugin.php 通常长这样——
<?php
/**
* Plugin Name: My Plugin
* Version: 0.1.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
define( 'MY_PLUGIN_PATH', plugin_dir_path( __FILE__ ) );
define( 'MY_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
require_once MY_PLUGIN_PATH . 'bootstrap.php';
bootstrap.php 负责把自动加载挂好、把主类实例化,然后让主类自己去决定什么时候注册钩子。这样入口文件永远干净,调试时我一眼就知道"程序从哪进"。
二、自动加载:别用 require 堆路径
早期我手写一堆 require_once,改个目录结构就报错。现在直接用 composer 的 PSR-4,composer.json 里加一段:
{
"autoload": {
"psr-4": {
"MyPlugin\\": "src/"
}
}
}
本地开发时 composer dump-autoload 随时刷新映射。生产打包再考虑优化成 classmap,开发阶段 PSR-4 最省事。
三、本地调试环境:不是"能跑 WordPress"就行
我试过几种方案,最后固定用 Docker + Xdebug + VS Code,因为能精确控制 PHP 版本和扩展,且断点能穿透到插件代码里。
我的 docker-compose.yml 核心片段(PHP 服务部分):
services:
wordpress:
image: wordpress:6.4-php8.2-apache
volumes:
- ./wordpress:/var/www/html
- ./my-plugin:/var/www/html/wp-content/plugins/my-plugin # 插件挂载为卷
- ./php.ini:/usr/local/etc/php/conf.d/xdebug.ini # 覆盖配置
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DB_USER: root
WORDPRESS_DB_PASSWORD: secret
ports:
- "8080:80"
xdebug.ini 内容(Xdebug 3 写法):
zend_extension=xdebug.so
xdebug.mode=debug
xdebug.start_with_request=yes
xdebug.client_host=host.docker.internal
xdebug.client_port=9003
xdebug.log=/tmp/xdebug.log
VS Code 的 .vscode/launch.json:
{
"version": "0.2.0",
"configurations": [
{
"name": "Listen for Xdebug",
"type": "php",
"request": "launch",
"port": 9003,
"pathMappings": {
"/var/www/html/wp-content/plugins/my-plugin": "${workspaceFolder}"
}
}
]
}
这里有个坑:pathMappings 必须精确映射到插件目录,不能只映射到 /var/www/html,否则断点会飘到 WordPress 核心文件里,或者干脆不生效。我为此浪费过两小时。
四、让错误"炸"在屏幕上:开发期的错误级别
插件入口或者 bootstrap.php 里,开发环境我会强制打开错误显示:
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_reporting( E_ALL );
ini_set( 'display_errors', '1' );
}
配合 wp-config.php 里的 define( 'WP_DEBUG', true ); 和 define( 'WP_DEBUG_LOG', true );,错误同时进文件。但注意:生产环境绝对不要 display_errors,这个开关我后面会包进环境判断里。
五、一个最小可运行的"骨架插件"
把上面串起来,一个能下断点、有目录层级、能自动加载的最小插件长这样。核心类 src/Core/Plugin.php:
<?php
namespace MyPlugin\Core;
class Plugin {
public function __construct() {
add_action( 'admin_menu', [ $this, 'register_menu' ] );
}
public function register_menu(): void {
add_menu_page(
'My Plugin',
'My Plugin',
'manage_options',
'my-plugin',
[ $this, 'render_page' ]
);
}
public function render_page(): void {
// 故意留一个断点位置,测试 Xdebug 是否穿透
$version = get_plugin_data( MY_PLUGIN_PATH . 'my-plugin.php' )['Version'];
printf( '<div class="wrap"><h1>My Plugin %s</h1></div>', esc_html( $version ) );
}
}
bootstrap.php 里实例化:
<?php
require_once MY_PLUGIN_PATH . 'vendor/autoload.php';
use MyPlugin\Core\Plugin;
new Plugin();
装完插件、在 render_page 里打个断点、F5 启动监听、刷新后台页面——如果 VS Code 能停住,整个链路就通了。
六、我踩过的两个具体坑
1. Docker 卷挂载后插件"消失"
WordPress 官方镜像的 wp-content/plugins 是镜像自带的,我把本地插件目录挂载进去时,如果路径写错,会看到一个空目录或者插件被覆盖。解决:确保挂载目标路径和容器内实际路径一致,且 docker-compose down -v 清除旧卷再重建。
2. Xdebug 3 的端口变化
Xdebug 2 默认 9000,Xdebug 3 改成 9003。我本地环境混过两个版本,launch.json 里端口对不上时,调试器永远"监听中"但不断住。现在我的 xdebug.ini 里显式写死 client_port=9003,不再依赖默认。
七、下一步:把测试接进来
目录里留了 tests/,我目前用 phpunit + brain/monkey 做单元测试,用 wp-env 做集成测试。但这块还在磨合,等跑顺了再开帖记录。
如果你也在搭第一个插件的本地环境,欢迎贴出你的目录结构或者踩过的坑。特别是用 LocalWP、Lando 或者其他方案的老哥,想听听实际体验对比。

