如何设计一个可维护的 PHP 后台系统?元点Admin 分层架构实践
一、问题根源:PHP 项目为什么越写越乱?
几乎每一个有过中型 PHP 项目经历的开发者,都踩过同一个坑。
项目初期,代码写得很快。Controller 里几十行搞定一个接口,Db::table('article')->where(...)->select() 随手就写,逻辑简洁直接。上线顺利,产品满意,团队士气高涨。
然后,需求开始叠加。
三个月后,一个"创建文章"的 Controller 方法可能已经膨胀到两三百行:查分类、校验权限、写数据库、更新缓存、发消息通知、记操作日志……全部混在一起。新人接手时,光是读懂这个方法就要花半天。改一个地方,不知道会不会影响另一处。测试?基本靠手点。
这不是某个团队特有的问题,而是 没有分层约束的必然结果。
PHP 的灵活性是双刃剑。它允许你在任何地方写任何代码,这在原型阶段是优势,在工程化阶段是隐患。常见的"乱"有以下几种典型症状:
症状一:Controller 直接写 SQL。 业务逻辑和数据访问混在一起,Controller 又厚又脆,根本无法单元测试。
症状二:业务逻辑散落在各处。 同样的"检查用户余额"逻辑,在下单接口写一遍,在充值回调写一遍,在管理后台又写一遍。日后规则变了,漏改一处就是 Bug。
症状三:改一处坏三处。 因为没有明确的依赖边界,模块之间耦合严重。一个看似简单的字段改名,可能触发连锁反应,让你在代码库里追踪好几个小时。
症状四:事件副作用污染主流程。 发邮件、写日志、清缓存等操作直接写在业务逻辑里,一旦某个副作用失败,整个事务回滚,用户收到莫名其妙的错误。
解决这些问题的答案,不是换框架,而是建立清晰的架构分层。
元点Admin(ydadmin)在 v1.3.0 版本中完成了一次系统性的架构重构,将整个后台系统规范为四层架构,并引入自动依赖注入与事件驱动机制。本文将完整还原这套设计思路,并附真实代码示例。
二、四层架构全景:职责边界一目了然
元点Admin 的请求处理流程如下:
请求 → Controller → Service → Repository → Model
↓
Listener(事件驱动副作用)
每一层只做自己该做的事,严禁越界。这不仅仅是"最佳实践"的口号——在 ydadmin 的代码库中,这些约束被写入了 Code Review 规范,并在 v1.3.0 重构中逐一落地。
分层职责总览
| 层 | 目录 | 职责 | 禁止 |
|---|---|---|---|
| Controller | app/adminapi/controller/v1/ | 接收请求、参数校验、调用 Service、返回响应 | 不直接操作 Repository 或 Model |
| Service | app/service/ | 业务逻辑编排、事务管理、触发事件 | 不直接用 Db::table() 或 Model 静态方法 |
| Repository | app/repository/ | 数据访问封装、所有 ORM 查询 | 不包含业务逻辑 |
| Model | app/model/ | ORM 映射、关联关系、访问器/修改器 | 不包含查询逻辑 |
| Listener | app/listener/ | 处理副作用(日志、通知、缓存清理) | 不影响主流程 |
这张表格看起来简单,但背后的设计哲学值得展开讲——每一层的"禁止"项,和"职责"项同等重要。
"禁止"项定义了边界。正是这些边界,让代码在规模扩大后依然可读、可测、可维护。举个例子:如果 Service 可以直接调用 Db::table(),那它和没有分层有什么区别?Repository 层存在的意义,就是把所有数据访问集中在一处,让 Service 只关心业务,让 Repository 只关心数据。
三、每层详解:代码说话
3.1 Controller 层——轻量的入口
Controller 是请求的第一道门。它的职责很纯粹:接收参数、校验格式、调用 Service、返回结果。任何业务判断、数据库操作,都不属于这里。
// app/adminapi/controller/v1/article/ArticleController.php
namespace app\adminapi\controller\v1\article;
use app\adminapi\controller\BaseAdminController;
use app\service\ArticleService;
use think\Request;
class ArticleController extends BaseAdminController
{
protected ArticleService $articleService;
/**
* 创建文章
*/
public function create(Request $request): \think\Response
{
$params = $request->post();
// 参数校验:只做格式检查,不做业务判断
$this->validate($params, [
'title' => 'require|max:200',
'category_id' => 'require|integer',
'content' => 'require',
]);
// 调用 Service,拿结果,返回响应
$article = $this->articleService->createArticle($params);
return $this->success('创建成功', $article);
}
}
注意:Controller 里没有一行 SQL,没有一行业务判断(比如"分类是否存在"),只有参数接收和 Service 调用。
3.2 Service 层——业务逻辑的核心
Service 是业务的编排中心。它负责:检查业务规则、管理事务、组合多个 Repository 调用、触发领域事件。
// app/service/ArticleService.php
namespace app\service;
use app\repository\ArticleRepository;
use app\repository\CategoryRepository;
use app\exception\BusinessException;
class ArticleService extends Service
{
protected ArticleRepository $articleRepository;
protected CategoryRepository $categoryRepository;
/**
* 创建文章
*/
public function createArticle(array $data): array
{
// 业务规则检查:分类必须存在且启用
$category = $this->categoryRepository->findById($data['category_id']);
if (!$category || $category['status'] !== 1) {
throw new BusinessException('所选分类不存在或已禁用');
}
// 数据补充
$data['status'] = 0; // 默认草稿
$data['create_time'] = time();
// 事务管理
return $this->transaction(function () use ($data) {
$article = $this->articleRepository->create($data);
// 触发事件,通知 Listener 处理副作用
$this->trigger('article.created', [
'article_id' => $article['id'],
'admin_id' => $data['admin_id'] ?? 0,
'category_id' => $data['category_id'],
]);
return $article;
});
}
}
Service 只调用 Repository 方法,不直接写 SQL。事务由 Service 管理,副作用通过事件触发。
3.3 Repository 层——数据访问的封装
Repository 是数据库的唯一入口。所有 ORM 查询都在这里,不在 Service,不在 Controller。
// app/repository/ArticleRepository.php
namespace app\repository;
use app\model\Article;
class ArticleRepository extends Repository
{
protected string $modelClass = Article::class;
/**
* 创建文章记录
*/
public function create(array $data): array
{
$model = new Article();
$model->save($data);
return $model->toArray();
}
/**
* 按条件分页查询文章
*/
public function paginate(array $filters = [], int $page = 1, int $pageSize = 20): array
{
$query = Article::where('is_delete', 0);
if (!empty($filters['category_id'])) {
$query->where('category_id', $filters['category_id']);
}
if (!empty($filters['keyword'])) {
$query->whereLike('title', '%' . $filters['keyword'] . '%');
}
return $query->order('create_time', 'desc')
->paginate($pageSize, false, ['page' => $page])
->toArray();
}
/**
* 根据 ID 查询单条记录
*/
public function findById(int $id): ?array
{
$record = Article::find($id);
return $record ? $record->toArray() : null;
}
}
Repository 不做任何业务判断。它只负责"怎么从数据库取数据",不关心"为什么取"。这让 Repository 方法高度可复用——同一个 findById,可以被多个不同的 Service 调用。
3.4 Model 层——ORM 映射与关联
Model 是数据库表的 PHP 映射。它定义表结构、关联关系、访问器和修改器,但不包含查询逻辑。
// app/model/Article.php
namespace app\model;
use think\Model;
class Article extends Model
{
protected $table = 'article';
// 自动时间戳
protected $autoWriteTimestamp = true;
protected $createTime = 'create_time';
protected $updateTime = 'update_time';
// 关联分类
public function category(): \think\model\relation\BelongsTo
{
return $this->belongsTo(Category::class, 'category_id');
}
// 关联作者
public function author(): \think\model\relation\BelongsTo
{
return $this->belongsTo(Admin::class, 'admin_id');
}
// 访问器:状态文字化
public function getStatusTextAttr($value, $data): string
{
$map = [0 => '草稿', 1 => '已发布', 2 => '已下线'];
return $map[$data['status']] ?? '未知';
}
}
Model 中没有 where,没有 select,没有任何查询逻辑。这是它和 Repository 最本质的分工:Model 描述"是什么",Repository 描述"怎么取"。
四、自动依赖注入:告别手动 new
在传统 PHP 项目中,依赖管理往往是个头疼的问题。Service A 依赖 Repository B 和 C,你需要在构造函数里逐一 new,或者维护一个复杂的容器配置。
元点Admin 采用了基于属性声明的自动依赖注入机制。规则非常简单:在类中声明 protected 类型属性,框架在实例化时自动完成注入,无需手动 new,也无需额外配置。
class ArticleService extends Service
{
// 只需声明属性和类型,框架自动注入实例
protected ArticleRepository $articleRepository;
protected CategoryRepository $categoryRepository;
protected TagRepository $tagRepository;
public function createArticle(array $data): array
{
// 直接使用,无需 new,无需构造函数注入
$category = $this->categoryRepository->findById($data['category_id']);
// ...
}
}
这套机制的背后,是基类 Service 中的 __get 魔术方法。当你访问 $this->categoryRepository 时,如果属性尚未初始化,__get 会读取属性的类型声明,通过容器解析出对应实例并缓存在对象上。整个过程对调用者完全透明。
这带来了几个显著好处:
1. 代码更简洁。 Service 类只需声明自己依赖哪些 Repository,不需要写一大段构造函数。
2. 懒加载,按需初始化。 如果某个方法根本没有被调用,对应的 Repository 实例就不会被创建,节省资源。
3. 测试友好。 在单元测试中,可以直接给属性赋值一个 Mock 对象,覆盖自动注入的实例:
// 单元测试示例
$service = new ArticleService();
$service->categoryRepository = $this->createMock(CategoryRepository::class);
$service->categoryRepository->method('findById')->willReturn(['id' => 1, 'status' => 1]);
$result = $service->createArticle(['category_id' => 1, 'title' => '测试']);
$this->assertNotEmpty($result['id']);
4. 依赖关系显式可见。 看一眼 Service 的属性声明,就能知道它依赖哪些数据层,不需要翻构造函数或者全文搜索。
五、事件驱动副作用:让主流程保持纯净
一个"创建文章"的操作,核心逻辑只有一件事:把数据写进数据库。但它往往还伴随着一堆"副作用":写操作日志、更新文章数量统计缓存、通知审核员……
如果把这些副作用全部塞进 Service,会带来两个问题:
- 主流程被污染:Service 方法越来越长,核心逻辑被淹没在副作用代码里。
- 副作用失败影响主流程:一个日志写入失败,整个创建操作回滚,用户收到神秘错误。
元点Admin 的解决方案是 Listener 机制——通过事件将副作用从主流程中解耦。
触发事件
在 Service 中,通过 trigger() 方法触发一个命名事件:
// app/service/ArticleService.php
$this->trigger('article.created', [
'article_id' => $article['id'],
'admin_id' => $data['admin_id'] ?? 0,
'category_id' => $data['category_id'],
]);
trigger() 调用是同步的,但即使某个 Listener 内部抛出异常,也不会影响已完成的数据库事务。
注册监听器
事件到监听器的映射,统一在 app/event.php 中配置:
// app/event.php
return [
'listen' => [
'article.created' => [
\app\listener\article\RecordArticleLog::class,
\app\listener\article\UpdateCategoryCount::class,
\app\listener\article\NotifyReviewer::class,
],
'admin.login.success' => [
\app\listener\admin\RecordLoginLog::class,
\app\listener\admin\UpdateLastLoginTime::class,
],
],
];
编写监听器
每个 Listener 只做一件事,结构简单:
// app/listener/article/RecordArticleLog.php
namespace app\listener\article;
class RecordArticleLog
{
public function handle(array $data): void
{
// 记录文章操作日志,失败不影响主流程
try {
// 写日志逻辑...
\app\model\OperateLog::create([
'module' => 'article',
'action' => 'create',
'target_id' => $data['article_id'],
'admin_id' => $data['admin_id'],
'time' => time(),
]);
} catch (\Throwable $e) {
// 静默处理,不向上抛出
logger()->warning('RecordArticleLog failed: ' . $e->getMessage());
}
}
}
判断标准:副作用还是主流程?
这是一个常见的架构决策题:某个操作,到底放 Service 还是 Listener?
元点Admin 的判断标准简洁有力:
操作失败不影响主流程 → Listener;操作必须成功 → Service。
| 操作 | 归属 | 原因 |
|---|---|---|
| 扣减库存 | Service | 必须成功,失败则业务不通 |
| 写操作日志 | Listener | 失败不影响用户操作 |
| 清理缓存 | Listener | 失败最多短暂数据不一致 |
| 校验用户权限 | Service | 必须成功,失败则拒绝请求 |
| 发邮件通知 | Listener | 失败不影响核心流程 |
这个标准一旦确立,团队成员在写代码时就有了明确的决策依据,不再靠"感觉"判断。
六、实际案例:「创建文章」功能的完整流转
将以上四层架构和事件机制串联起来,一次"创建文章"请求的完整旅程如下:
第一站:Controller 接收请求
ArticleController::create() 接收 POST 参数,调用 validate() 检查必填项和格式,然后将 $params 原样传给 ArticleService::createArticle()。此时 Controller 的工作已经结束。
第二站:Service 执行业务逻辑
ArticleService 首先调用 CategoryRepository::findById() 确认分类存在且状态正常。检查通过后,补充默认字段(状态、时间),开启数据库事务。事务内调用 ArticleRepository::create() 写入记录。写入成功后,触发 article.created 事件。事务提交,返回新文章数据。
第三站:Repository 写入数据库
ArticleRepository::create() 实例化 Article Model,调用 save() 写库,返回 toArray()。它不知道调用方是谁,也不关心文章是草稿还是正式——只管写入。
第四站:Listener 处理副作用
article.created 事件触发后,三个 Listener 依次执行:写操作日志、更新分类文章计数缓存、通知审核员。每个 Listener 独立运行,互不干扰,任何一个失败都不影响已成功的数据写入。
最终:Controller 返回响应
Service 返回新文章数据,Controller 封装为统一响应格式,返回给客户端。
整个流程中,每一层的职责清晰,没有任何一层越界。从 Request 进来到 Response 出去,代码路径完全可预测、可追踪。
七、分层架构带来的三重价值
经过 v1.3.0 的系统性重构,元点Admin 的架构实践最终落地为三重可量化的价值。
可测试性: 每一层都可以独立测试。Repository 层可以用内存数据库跑单元测试,Service 层可以 Mock Repository,Controller 层可以 Mock Service。不再需要启动完整应用才能验证一段业务逻辑。
可维护性: 当产品需求变更时,改动范围是可预测的。数据库字段改了 → 只改 Repository 和 Model;业务规则变了 → 只改 Service;接口格式变了 → 只改 Controller。"改一处坏三处"的噩梦大幅减少。
团队协作: 前后端、多人团队开发时,分层架构提供了天然的工作分界线。A 负责 Controller 和参数校验,B 负责 Service 业务逻辑,C 负责 Repository 查询优化,互不阻塞。Code Review 时,也能按层快速定位问题所在。
这套架构不是为了"架构而架构",而是在真实项目中不断踩坑、重构后沉淀下来的工程实践。它已经在元点Admin 中稳定运行,支撑着完整的后台管理体系:权限管理、菜单配置、用户管理、操作日志……每一个模块都遵循同一套分层规范。
立即体验元点Admin
如果你正在构建一套 PHP 后台系统,或者正在为现有项目的架构混乱而烦恼,元点Admin 提供了一个开箱即用的分层架构参考实现。
- Gitee:gitee.com/yuandianxit…
- GitHub: github.com/yuandianxit…
- 在线 Demo: admin.dev007.cn
- 开发文档: docs.dev007.cn/admin/
如果这套架构设计对你有帮助,欢迎在 GitHub 上给项目点一个 Star ⭐。你的支持是持续维护和优化这个项目的最大动力。
有任何架构上的问题或建议,也欢迎在 GitHub Issues 中讨论,或者在评论区留言。
关键词:PHP 分层架构 / ThinkPHP 架构设计 / Repository 模式 / Service 层 / PHP 代码架构