如果你正在用ThinkPHP 8做项目,大概率已经注意到官方对多应用模式的重视程度明显提高了。之前用6.0的时候多应用还是个可选方案,到了8.0往后几乎是默认推荐的做法。刚好前段时间把一个旧项目从单应用拆成了多应用架构,顺带把API层也重构了一遍,踩了不少坑,趁记忆还新鲜,整理出来给有需要的同学参考。
一、为什么要折腾多应用模式
说个很现实的情况:大部分项目跑着跑着就会膨胀。起初可能就一个前端入口加几个接口,后来需求堆上来——管理后台要独立、要给第三方开放API、可能还得接个小程序端。如果所有逻辑都塞在一个应用里,控制器目录会变成灾难现场,配置文件互相打架,路由文件翻半天找不到对应的方法。
ThinkPHP 8的多应用模式解决的就是这个问题。它允许你在一个项目里跑多个独立的应用,比如:
- admin — 管理后台接口,内部使用
- api — 对外开放的RESTful接口
- web — 传统的页面渲染应用
每个应用有自己的控制器、模型、配置、路由、中间件,互不干扰。但公共的东西(比如基础模型类、工具函数、第三方扩展)可以放在项目根层的common目录下共享。这个架构对中大型项目来说确实舒服不少。
二、环境与初始化
先确保本机PHP版本不低于8.0,这是ThinkPHP 8的硬性要求。习惯用8.1或8.2的话更好,JIT和枚举类型在写业务的时候偶尔能派上用场。
创建项目:
composer create-project topthink/think tp8-api-demo
cd tp8-api-demo
项目建好之后,默认是单应用模式。要开启多应用,先把app目录下的controller、model、view等文件夹删掉(别犹豫,后面会按应用重新建),然后安装多应用扩展:
composer require topthink/think-multi-app
安装完成后,在app目录下新建几个子目录作为应用。我们按实际需求来:
app/
├── admin/ # 管理后台应用
│ ├── controller/
│ ├── model/
│ ├── middleware/
│ └── route/
├── api/ # 对外开放API应用(本文重点)
│ ├── controller/
│ ├── model/
│ ├── middleware/
│ ├── validate/
│ └── route/
├── common/ # 公共模块
│ ├── controller/
│ ├── model/
│ └── service/
└── AppService.php # 应用服务(多应用会自动生成)
到这一步,访问http://你的域名/admin和http://你的域名/api应该都能分别进到对应应用了。如果报了404,先检查一下config/app.php里的with_route是否设为true,以及域名是否配置到了public目录。
三、统一响应格式——先把”壳”搭好
写API最忌讳的就是响应格式五花八门。一会儿返回{"code":0,"data":...},一会儿又变成{"status":"success"},前端对接的人会想打人的。在动手写业务逻辑之前,先把响应格式定死。
在app/common/controller下建一个基类ApiController.php,所有API控制器都继承它:
<?php
namespace appcommoncontroller;
use thinkexceptionHttpResponseException;
use thinkResponse;
class ApiController
{
/**
* 成功响应
* @param mixed $data 返回数据
* @param string $message 提示信息
* @param int $code 业务状态码
*/
protected function success($data = [], string $message = '操作成功', int $code = 200): Response
{
$result = [
'code' => $code,
'message' => $message,
'data' => $data,
'timestamp' => time(),
];
throw new HttpResponseException(json($result, 200));
}
/**
* 失败响应
* @param string $message 错误信息
* @param int $code 业务状态码
* @param int $httpCode HTTP状态码
*/
protected function error(string $message = '操作失败', int $code = 400, int $httpCode = 200): Response
{
$result = [
'code' => $code,
'message' => $message,
'data' => null,
'timestamp' => time(),
];
throw new HttpResponseException(json($result, $httpCode));
}
/**
* 分页数据响应
*/
protected function page($list, int $total, int $page, int $pageSize): Response
{
return $this->success([
'list' => $list,
'total' => $total,
'page' => $page,
'pageSize' => $pageSize,
'hasMore' => ($page * $pageSize) < $total,
]);
}
}
这里有一个小细节值得说一下:我用的是throw new HttpResponseException来终止执行并返回响应,而不是直接return json()。这样做的好处是可以在中间件或者异常处理器里统一拦截,避免层层return导致的代码冗余。这个习惯是从Laravel那边借鉴过来的,实测在TP8里同样好使。
然后建一个异常处理类,把框架报错也统一成上面的格式。在app/api目录下创建exception/Handler.php:
<?php
namespace appapiexception;
use thinkexceptionHandle;
use thinkResponse;
use Throwable;
class Handler extends Handle
{
public function render($request, Throwable $e): Response
{
// 参数验证异常
if ($e instanceof thinkexceptionValidateException) {
return json([
'code' => 422,
'message' => $e->getMessage(),
'data' => null,
'timestamp' => time(),
], 200);
}
// 调试模式下保留原始错误信息
if (app()->isDebug()) {
return parent::render($request, $e);
}
// 生产环境统一返回
return json([
'code' => 500,
'message' => '服务器内部错误,请稍后重试',
'data' => null,
'timestamp' => time(),
], 200);
}
}
别忘了在app/api下的provider.php里注册这个异常处理器(没有就新建一个):
<?php
use appapiexceptionHandler;
return [
'thinkexceptionHandle' => Handler::class,
];
四、JWT认证——别再存session了
RESTful API讲究无状态,session那套东西在前后端分离的场景下确实不太合适。JWT是目前最主流的方案,TP8也有现成的扩展可以用。
安装thans/tp-jwt-auth:
composer require thans/tp-jwt-auth
发布配置文件:
php think jwt:create
这会在config目录下生成jwt.php。里面可以调整token有效期、密钥、签发者等参数。默认配置其实就够用,但建议把ttl(过期时间)根据业务场景调整一下,别用默认的3600秒。我的习惯是access_token设2小时,refresh_token设7天,具体看项目安全要求。
然后在app/api/middleware下建一个认证中间件Auth.php:
<?php
namespace appapimiddleware;
use thansjwtfacadeJWTAuth;
use thinkexceptionHttpResponseException;
class Auth
{
public function handle($request, Closure $next)
{
try {
// 验证token,同时获取用户信息
$payload = JWTAuth::auth();
// 把用户ID注入到请求中,后续控制器可以直接拿
$request->userId = $payload['uid'] ?? 0;
} catch (Exception $e) {
throw new HttpResponseException(json([
'code' => 401,
'message' => '登录已过期,请重新登录',
'data' => null,
'timestamp' => time(),
], 200));
}
return $next($request);
}
}
在路由里给需要认证的接口组套上这个中间件就行。后面路由设计的部分会具体演示。
登录逻辑这里简单贴一下核心代码。在app/api/controller下建AuthController.php:
<?php
namespace appapicontroller;
use appcommoncontrollerApiController;
use thinkRequest;
class AuthController extends ApiController
{
/**
* 用户登录
*/
public function login(Request $request)
{
$username = $request->param('username');
$password = $request->param('password');
if (empty($username) || empty($password)) {
return $this->error('用户名和密码不能为空');
}
// 这里是模拟查询,实际项目请替换为数据库查询
$user = appapimodelUser::where('username', $username)->find();
if (!$user || !password_verify($password, $user->password)) {
return $this->error('用户名或密码错误', 401);
}
// 签发token
$token = thansjwtfacadeJWTAuth::builder(['uid' => $user->id]);
return $this->success([
'access_token' => $token['access_token'],
'refresh_token' => $token['refresh_token'],
'expires_in' => $token['expires_in'],
'user' => [
'id' => $user->id,
'username' => $user->username,
'avatar' => $user->avatar,
],
], '登录成功');
}
}
这里有个容易被忽略的点:密码验证务必用password_verify和password_hash这套PHP内置函数,别自己瞎拼接md5加盐。都2024年了,bcrypt是底线。
五、路由设计与版本控制
API版本管理是个老生常谈的话题。我的做法是在URL路径里带版本号,比如/api/v1/articles。这种方式最直观,前端对接的时候不会搞混,也方便后续做灰度升级。
在app/api/route下新建route.php:
<?php
use thinkfacadeRoute;
// 不需要认证的接口
Route::group('v1', function () {
// 认证相关
Route::post('auth/login', 'Auth/login');
Route::post('auth/register', 'Auth/register');
// 公开的文章列表(游客可看)
Route::get('articles', 'Article/index');
Route::get('articles/:id', 'Article/read');
})->prefix('v1.');
// 需要登录认证的接口
Route::group('v1', function () {
// 文章管理
Route::post('articles', 'Article/create');
Route::put('articles/:id', 'Article/update');
Route::delete('articles/:id', 'Article/delete');
// 用户相关
Route::get('user/profile', 'User/profile');
Route::put('user/profile', 'User/updateProfile');
})->prefix('v1.')->middleware(appapimiddlewareAuth::class);
注意这里我用了一个小技巧:把同一个版本号的接口按是否需要认证拆成两个group。这样中间件只作用在需要的地方,不会影响公开接口。见过不少项目把所有接口都套上认证中间件,然后白名单排除,逻辑反过来写其实更容易出漏洞。
另外prefix('v1.')这个写法会让控制器也按版本号分目录存放。也就是说你的控制器实际路径是app/api/controller/v1/Article.php。这样做的好处是将来如果要升级v2版本,直接在controller下新建v2目录,路由里改个前缀就行,v1的代码完全不用动。
六、数据验证——别把校验逻辑写在控制器里
控制器应该尽量薄,这是我的一个执念。数据校验、业务逻辑、数据库操作各归各位,代码才不容易变成意大利面条。
ThinkPHP 8的验证器用起来很顺手。在app/api/validate下建ArticleValidate.php:
<?php
namespace appapivalidate;
use thinkValidate;
class ArticleValidate extends Validate
{
protected $rule = [
'title' => 'require|max:120',
'content' => 'require|min:20',
'category_id'=> 'require|integer|gt:0',
'tags' => 'array',
'status' => 'in:draft,published',
];
protected $message = [
'title.require' => '文章标题不能为空',
'title.max' => '文章标题最多120个字符',
'content.require' => '文章内容不能为空',
'content.min' => '文章内容至少20个字符',
'category_id.require' => '请选择文章分类',
'category_id.integer' => '分类参数格式错误',
'category_id.gt' => '分类参数必须大于0',
'status.in' => '文章状态仅支持draft或published',
];
// 场景定义:创建和更新时的规则可能不一样
protected $scene = [
'create' => ['title', 'content', 'category_id', 'tags', 'status'],
'update' => ['title', 'content', 'category_id', 'tags', 'status'],
];
}
在控制器里调用验证只需要一行:
// 在Article控制器的create方法中
$validate = new appapivalidateArticleValidate;
$validate->scene('create')->check($request->post());
// 如果验证不通过,框架会自动抛出ValidateException
// 而我们之前在异常处理器里已经统一处理了这类异常
验证器和异常处理器配合起来,控制器里完全不需要写if-else判断参数是否合法,代码干净很多。
七、实战案例——文章管理API完整实现
前面铺垫了那么多,现在来一个完整的案例把流程串起来。下面是一个文章管理功能的完整实现,包含列表查询、详情查看、创建、更新和删除。
首先是控制器app/api/controller/v1/Article.php:
<?php
namespace appapicontrollerv1;
use appcommoncontrollerApiController;
use appapivalidateArticleValidate;
use appapimodelArticle as ArticleModel;
use thinkRequest;
class Article extends ApiController
{
/**
* 文章列表(公开接口,支持分页和筛选)
*/
public function index(Request $request)
{
$page = $request->param('page', 1);
$pageSize = $request->param('pageSize', 15);
$keyword = $request->param('keyword', '');
$status = $request->param('status', 'published');
$query = ArticleModel::where('status', $status);
if (!empty($keyword)) {
$query->whereLike('title', "%{$keyword}%");
}
$total = $query->count();
$list = $query->field('id,title,summary,cover,author,create_time,view_count')
->order('create_time', 'desc')
->page($page, $pageSize)
->select()
->toArray();
return $this->page($list, $total, (int)$page, (int)$pageSize);
}
/**
* 文章详情
*/
public function read($id)
{
$article = ArticleModel::field('id,title,content,cover,author,category_id,tags,status,create_time,view_count')
->find($id);
if (!$article) {
return $this->error('文章不存在', 404);
}
// 阅读量自增
$article->inc('view_count')->update();
return $this->success($article->toArray());
}
/**
* 创建文章(需认证)
*/
public function create(Request $request)
{
// 数据验证
$validate = new ArticleValidate;
$validate->scene('create')->check($request->post());
$data = [
'title' => $request->post('title'),
'content' => $request->post('content'),
'summary' => mb_substr(strip_tags($request->post('content')), 0, 200),
'category_id' => $request->post('category_id'),
'tags' => json_encode($request->post('tags', []), JSON_UNESCAPED_UNICODE),
'status' => $request->post('status', 'draft'),
'author' => $request->userId, // 从认证中间件注入的
'create_time' => date('Y-m-d H:i:s'),
];
$article = ArticleModel::create($data);
return $this->success(['id' => $article->id], '文章创建成功');
}
/**
* 更新文章(需认证,且只能修改自己的文章)
*/
public function update(Request $request, $id)
{
$article = ArticleModel::find($id);
if (!$article) {
return $this->error('文章不存在', 404);
}
if ($article->author != $request->userId) {
return $this->error('无权编辑他人的文章', 403);
}
$validate = new ArticleValidate;
$validate->scene('update')->check($request->post());
$updateData = $request->only(['title', 'content', 'category_id', 'tags', 'status']);
if (!empty($updateData['content'])) {
$updateData['summary'] = mb_substr(strip_tags($updateData['content']), 0, 200);
}
if (!empty($updateData['tags'])) {
$updateData['tags'] = json_encode($updateData['tags'], JSON_UNESCAPED_UNICODE);
}
$article->save($updateData);
return $this->success([], '文章更新成功');
}
/**
* 删除文章(需认证)
*/
public function delete(Request $request, $id)
{
$article = ArticleModel::find($id);
if (!$article) {
return $this->error('文章不存在', 404);
}
if ($article->author != $request->userId) {
return $this->error('无权删除他人的文章', 403);
}
// 软删除,数据表需要有delete_time字段
$article->delete();
return $this->success([], '文章已删除');
}
}
模型层app/api/model/Article.php:
<?php
namespace appapimodel;
use thinkModel;
use thinkmodelconcernSoftDelete;
class Article extends Model
{
use SoftDelete;
protected $name = 'article';
protected $deleteTime = 'delete_time';
protected $defaultSoftDelete = 0;
// 自动时间戳
protected $autoWriteTimestamp = true;
protected $createTime = 'create_time';
protected $updateTime = 'update_time';
// JSON字段自动转换
protected $json = ['tags'];
protected $jsonAssoc = true;
/**
* 关联分类
*/
public function category()
{
return $this->belongsTo(Category::class, 'category_id');
}
/**
* 作用域:仅查询已发布
*/
public function scopePublished($query)
{
$query->where('status', 'published');
}
}
对应的数据表结构参考(MySQL):
CREATE TABLE `article` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`title` varchar(120) NOT NULL COMMENT '标题',
`content` longtext NOT NULL COMMENT '正文',
`summary` varchar(300) DEFAULT '' COMMENT '摘要',
`cover` varchar(255) DEFAULT '' COMMENT '封面图',
`author` int unsigned NOT NULL COMMENT '作者用户ID',
`category_id` int unsigned NOT NULL COMMENT '分类ID',
`tags` json DEFAULT NULL COMMENT '标签',
`status` enum('draft','published') DEFAULT 'draft' COMMENT '状态',
`view_count` int unsigned DEFAULT '0' COMMENT '阅读数',
`create_time` datetime NOT NULL,
`update_time` datetime DEFAULT NULL,
`delete_time` int unsigned DEFAULT '0' COMMENT '软删除标记',
PRIMARY KEY (`id`),
KEY `idx_author` (`author`),
KEY `idx_category` (`category_id`),
KEY `idx_status_time` (`status`,`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章表';
整个流程走下来,从路由到控制器到验证到模型,链条很清晰。这里面有几个我踩过的坑值得单独说一下:
- 软删除的
delete_time字段类型:TP8默认用int存时间戳,不是datetime。如果你之前用的是Laravel迁移过来的表,注意字段类型要匹配,否则软删除不生效。 - JSON字段的处理:模型里设了
$json属性后,存入数据库会自动编码,取出来自动解码。但如果你的MySQL版本低于5.7不支持原生JSON类型,字段类型可以用text替代,不影响使用。 - 权限校验放在控制器还是中间件:上面更新和删除接口里,我手动判断了
$article->author != $request->userId。这种资源级别的权限控制放中间件里反而不灵活,直接在控制器里判断最直观。如果是角色级别的权限(比如只有管理员能访问某个模块),那放在中间件里更合适。
八、一些性能上的小建议
写完功能只是第一步,上线前还有几个地方值得优化:
- 数据库索引:上面表结构里已经加了
idx_status_time联合索引,列表查询的时候能走索引。实际项目中记得用EXPLAIN分析慢查询,别等线上报警了再补索引。 - JWT黑名单:用户退出登录或者修改密码后,旧的token应该失效。
thans/tp-jwt-auth支持缓存黑名单,把失效的token丢进Redis,中间件里多查一次就行。不过如果业务对实时性要求不高,等token自然过期也不是不行。 - 响应数据裁剪:上面
index方法里用field()指定了返回字段,列表接口不要查content这种大字段,能省不少传输时间和内存。 - 接口限流:对外的公开API最好加一层限流。可以用TP8的中间件配合Redis计数器实现,一分钟内同一IP超过阈值就返回429。这个不复杂但很实用,能挡住大部分恶意爬虫。
九、写在最后
ThinkPHP 8的多应用模式比6.0时期成熟了不少,目录结构清晰,配合中间件和验证器能把代码组织得比较舒服。这篇文章侧重实战,很多细节没法一一展开——比如队列、事件、缓存策略这些,但上面这套架子搭好之后,加这些功能都是水到渠成的事。
如果你正在用TP8做项目,建议从一开始就上多应用模式。哪怕现在只有一个应用,把架子预留好,将来扩展的时候会感谢自己当初的决定。另外代码规范方面,控制器保持薄、模型保持纯、验证独立成层,这三条原则坚持下来,项目的可维护性能提升不少。
文中所有代码都在本地跑通过,如果照着来遇到问题,多半是版本差异或者配置细节没对上,欢迎对照排查。

