项目结构
这篇回答「哪个目录放什么、我写的东西该落在哪里」。写法上的规矩——分层怎么划、失败怎么表示、鉴权怎么声明——在代码约定。
目录本身是约定的一部分:这棵树里没有空占位目录。一个目录存在,就意味着里面有实现;真要用到再建,不让空目录冒充已经落地的架构。
全树
后台:
admin/
├── app/
│ ├── config/ config.php、database.php、schedule.php
│ ├── console/ 控制台命令
│ ├── control/ 控制器,一个屏一个 ctl_*
│ ├── lang/{zh-cn,en}/ 后台自己的语言包
│ ├── middleware/ 只服务后台的中间件
│ ├── model/ 后台专属 Model(目前只有 auth)
│ ├── support/ 后台专属工具类
│ └── template/{ct}/{ac}.tpl Smarty 模板,一个动作一个
└── public/ 文档根:index.php 与 static/
接口:
api/
├── app/
│ ├── config/ config.php、database.php
│ ├── control/ 生成物,源是 api/contracts/
│ ├── lang/{zh-cn,en}/
│ ├── logic/ 业务入口,人写的
│ └── model/ api 专属 Model(目前只有 auth)
├── contracts/ 接口契约,改接口改这里
├── templates/ 生成器模板覆盖
└── public/ 文档根:index.php
其余目录不属于任何一个应用:
common/ 两个应用共用
├── config/ 两个应用确实共同覆盖的那两份
├── control/base.php 控制器基类
├── define/ 错误码
├── exception/ biz_exception
├── integration/ 第三方适配器
├── lang/{zh-cn,en}/ 共用语言包
├── middleware/catcher.php 异常渲染
├── model/ 业务 Model 与 Model 基类
├── service/ 事务边界
└── support/ 跨应用工具类
database/
├── migrations/ 建表与升级,唯一的表结构来源
└── seeders/ 内置数据(菜单树),不含账号
deploy/
├── nginx/ vhost 样例
└── cron/ crontab 条目样例
docs/ 项目文档,见下文
tests/{Unit,Feature}/ Unit 不连任何东西,Feature 要真库
data/ 运行时目录(缓存、日志、会话),不进版本库
plato.config.php 控制台的引导配置
phpstan.neon 静态分析范围
phpunit.xml 两个 testsuite 的划分
.env.example 环境变量清单,复制成 .env 再填
data/ 是运行时目录,.gitignore 兜住了整个 /data/。它按应用分开:data/admin、data/api、data/testing,由各自入口的 data_path 决定。这个目录要可写,但不能在文档根里。
入口与引导
每个入口做的是同一件事——plato::registry([...]) 然后 plato::run():
| 入口 | app_path | 谁在用 |
|---|---|---|
admin/public/index.php |
admin/app |
浏览器 |
plato.config.php |
默认 admin/app |
vendor/bin/plato |
还有一个:api/public/index.php,app_path 是 api/app,服务 API 客户端。(它单独列在表外,是因为按档增减的条目不能放进表格——HTML 注释会把表格从那一行截断,后面的行渲染成字面竖线。)
app_path 决定 Controller、Model、config、template、lang 的查找位置。data_path 也跟着分开,所以两个应用的缓存与日志不会互相盖掉。
命令行只认一个 app_path:默认是 admin,要用 api 那份配置就 PLATO_APP=api php vendor/bin/plato ...。迁移、调度、账号命令都从 admin 这一侧操作。
两个应用必须用不同的 controller_namespace(admin\control / api\control)。Composer 的 PSR-4 映射是进程级的:同一个 control\ 前缀映射到两个目录,先注册的那个会赢下所有查找,api 的请求就可能落进后台控制器。
另外两处差别是有意的:admin 开 session_start(会话认证,CSRF 令牌也绑在它上面),api 不开(客户端带 token,不发 cookie)。
common/ —— 两个应用共用的东西
判断标准是跨应用,不是「看起来通用」。只有后台用得上的东西放 admin/app/,哪怕它写得很像工具类。
| 目录 | 放什么 | 现在有 |
|---|---|---|
common/define/ |
错误码常量,一个域一个类 | code、admin_code、attachment_code |
common/exception/ |
业务异常 | biz_exception |
common/control/ |
控制器基类:信封、分页、校验、身份 | base |
common/middleware/ |
跨应用中间件 | catcher(唯一把异常变成响应的地方) |
common/model/ |
业务主体,一个 Model 默认管一张主表 | 基类 model,加后台域 admin、admin_role、admin_session、admin_login_log、admin_oplog、admin_menu、setting、schedule_state,以及两个应用共用的 attachment |
common/service/ |
事务边界,别的什么都不做 | 基类 service,加后台各域 |
common/support/ |
跨应用工具类 | boot、csrf、identity、lang、field_crypt、totp、disks |
common/integration/ |
第三方适配器,外部服务只从这里进来 | s3,以及各档次自带的邮件、短信与身份适配器 |
common/lang/ |
共用语言包,PHP 文件返回 key => text |
zh-cn、en |
common/config/ |
两个应用确实共同覆盖的配置 | config.php(中间件、语言)、database.php、storage.php |
common/config/ 不在框架的配置合并链上,它是被应用配置文件 require 进来再合并的。
storage.php 里两个本地盘的根目录都写成绝对路径,这一条必须留意:框架默认让 local 盘落到 plato::data_path('storage'),而 data_path 是按应用分的(后台是 data/admin,api 是 data/api),照默认走两个应用会各拿到一棵互相看不见的存储树。两个盘分别是 local(私有,只能经 /attachment/download 鉴权取)和 public(nginx 从 /uploads/ 直接服务)。
第三块盘是 s3,驱动是 common/integration/s3.php——它实现框架的 plato\storage\disk 接口,配置里直接写类名,所以既不用改 vendor/,也不用在启动时注册。只有配了 S3_BUCKET 它才存在:common\support\disks::names() 会把它整个漏掉,于是资源屏的筛选框、对账任务和上传都当它不在,没打算用对象存储的部署不用管它。凭据只从 .env 读,setting 表里不许出现。
上传落到哪块盘由两处决定,而且是一处压另一处:.env 的 STORAGE_DISK 是这套部署写哪块盘(也就是 common/config/storage.php 的 default,不指名盘的 storage::put() 用的也是它),后台 系统设置 → 文件存储 → 上传落到哪块盘 可以就上传这件事覆盖它,而这个设置项的初值就是「跟随 .env 的 STORAGE_DISK」。合并点在 common\service\attachment::upload_disk()。
这么接是因为反过来会骗人:设置项要是只能在后台改,STORAGE_DISK 就成了一个看起来管上传、实际什么都不管的变量——那正是它上线第一天的样子。
改哪一处都只影响之后上传的文件,attachment 每一行都记着自己写到了哪块盘。
App 用户域也在 common/ 里,虽然目前只有 api 读它:Model user、user_openid、user_session,Service user_auth,错误码 user_code,工具类 jwt、verify_code,第三方登录适配器 oidc、google_identity、apple_identity。放在这里是因为后台迟早要管这些数据,而不是因为 api 需要它。
admin/ —— 后台
Smarty 渲染的后台界面。一个屏对应一个控制器动作和一个模板,这一屏的 JavaScript 就在模板的 script 块里:
admin/app/control/ctl_setting.php 控制器动作 index
admin/app/template/setting/index.tpl 模板,含 script 块
admin/app/model/只放应用专属职责。现在只有auth——会话与权限判定是后台自己的事,业务数据一律在common/model/。admin/app/support/同理:menu、purviews、view、routes、dates、errors、qr、schedule_tasks。admin/app/console/是控制台命令:account(建账号、改密码)、prune(清理过期数据)、schedule(替换框架同名命令,加上停用开关与运行记录)、attachment(对账存储盘)。admin/app/config/schedule.php是计划任务的定义,跟代码一起进版本库、一起发布。后台界面能停用和恢复任务,不能新增任务或改执行时间。
前端约定(layui、没有构建步骤、Smarty 分隔符为什么是 <{ }>、layui.use 怎么写)见后台前端约定。
api/ —— 接口
api 比后台多一层,且控制器不是手写的:
api/contracts/auth.php 契约,人写的,改接口改这里
↓ vendor/bin/plato api:generate
api/app/control/ctl_auth.php 生成物,不要手改
↓ 调用
api/app/logic/auth_login.php 业务入口,人写的
↓
common/service/user_auth.php → common/model/user*
api/app/control/是生成物。 手改会被api/manifest.json挡住——它记着生成时的哈希,api:check会发现改动。api/app/logic/与api/app/model/是项目代码,生成器不覆盖。一个动作一个 Logic 文件(auth_login、auth_refresh……)。- 生成控制器的形状由
plato.config.php的api_contract回答:exception决定参数错误怎么答,成功答的是框架的resp::response()。本项目不覆盖生成器模板。 docs/api/openapi.json同样由api:generate产出。
契约怎么写、生成什么、哪些文件是人写的,见API 契约与代码生成。
不属于任何应用的目录
database/migrations/ 是表结构的唯一来源,没有 init.sql。文件名 YYYYMMDD_HHMMSS_description.php,表名一律写 #PB#_xxx,运行时由 DB_PREFIX 替换。字段约定与已建表见数据库设计。
database/seeders/ 只放内置数据。现在只有菜单树,没有账号,也不会有——种进仓库的账号意味着密码在代码里,且每个跑过 seeder 的部署都是同一个密码。第一个管理员用 admin:create 建。
deploy/ 是样例,不是被读取的配置:nginx vhost 拷进 nginx 的 conf.d/,crontab 条目拷进宿主机的 crontab。
tests/ 分两个 testsuite。Unit 不连任何东西,任何环境都能跑;Feature 要真库,连不上会 skip 而不是失败。
docs/ 是文档源加生成的静态站:*.md 是源,build.php 生成 site/*.html,manifest.json 是页面清单,assets/ 是样式与 vendored 的 swagger-ui。site/ 被 .gitignore 挡在仓库外,由 CI 每次推送重新生成并发布,所以改文档只改 .md,要本地预览再跑 php docs/build.php。新增一篇要往 manifest.json 的 pages 里按阅读顺序加一条,漏加的页面不会出现在站点里。
新代码放哪里
| 要加的东西 | 放哪里 |
|---|---|
| 后台的一个新屏 | admin/app/control/ctl_*.php + admin/app/template/{ct}/{ac}.tpl |
| 业务规则、查询、状态流转 | common/model/ |
| 需要事务的多步写入 | common/service/,只划事务边界 |
| 只有后台用得上的判定或渲染辅助 | admin/app/model/、admin/app/support/ |
| 新的错误码 | common/define/{域}_code.php,不要往 code 里堆 |
| 调用第三方服务 | common/integration/,业务代码不直接发 HTTP |
| 表结构变更 | database/migrations/ 新建一个文件,不改已经跑过的那个 |
| 界面文案 | common/lang/{locale}/ 或 {app}/app/lang/{locale}/ |
再加一条:一个新接口——在 api/contracts/ 写契约,跑 api:generate,然后在 api/app/logic/ 写实现。
简单的单表 CRUD 不建 Service,控制器直接调 Model。分层是用来划边界的,不是用来凑齐层数的。
生成物,不要手改
| 路径 | 谁生成 | 源 |
|---|---|---|
docs/site/*.html |
php docs/build.php |
docs/*.md |
vendor/ |
Composer | composer.json |
接口那一侧还有两个,源都是 api/contracts/,都由 plato api:generate 生成:api/app/control/ 与 docs/api/openapi.json。
vendor/lumnd/platophp/ 是框架,任何情况下不改——下一次 composer install 就没了。发现框架 Bug 到框架仓库里改,不要在业务侧写绕过它的补丁而不说。
命名
- 类名、文件名、命名空间一律蛇形小写,一个文件一个类,文件名等于类名。
- 前缀只保留框架强制的
ctl_。没有pub_、mod_、serv_这类前缀。 - 控制器
ctl_{域},模板{ct}/{ac}.tpl——两者靠路由对上,不靠约定俗成的记忆。
- api 的 Logic 是
{域}_{动作}:auth_login、auth_send_code。
- Migration 是
YYYYMMDD_HHMMSS_description.php,描述用英文小写下划线。 - 测试文件是
{被测对象}Test.php(驼峰),目录跟着被测代码的目录走。
发布时被裁掉的目录
composer create-project 装出来的树不一定是完整的:install.php 按三档裁剪,minimal 没有 api/、没有 App 用户域、没有契约生成。所以新加的文件如果只服务 api 或只服务常驻服务,要同时登记进 install.php 的 PROFILES,否则极简版会留下一个引用了已删类的文件。规则见仓库根目录的 AGENTS.md 第 12 节。