如何设计一个可维护的 PHP 后台系统?分层架构实践

8 阅读10分钟

如何设计一个可维护的 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 重构中逐一落地。

分层职责总览

目录职责禁止
Controllerapp/adminapi/controller/v1/接收请求、参数校验、调用 Service、返回响应不直接操作 Repository 或 Model
Serviceapp/service/业务逻辑编排、事务管理、触发事件不直接用 Db::table() 或 Model 静态方法
Repositoryapp/repository/数据访问封装、所有 ORM 查询不包含业务逻辑
Modelapp/model/ORM 映射、关联关系、访问器/修改器不包含查询逻辑
Listenerapp/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,会带来两个问题:

  1. 主流程被污染:Service 方法越来越长,核心逻辑被淹没在副作用代码里。
  2. 副作用失败影响主流程:一个日志写入失败,整个创建操作回滚,用户收到神秘错误。

元点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 提供了一个开箱即用的分层架构参考实现。

如果这套架构设计对你有帮助,欢迎在 GitHub 上给项目点一个 Star ⭐。你的支持是持续维护和优化这个项目的最大动力。

有任何架构上的问题或建议,也欢迎在 GitHub Issues 中讨论,或者在评论区留言。


关键词:PHP 分层架构 / ThinkPHP 架构设计 / Repository 模式 / Service 层 / PHP 代码架构