熟悉 ThinkPHP 的朋友应该知道,以前我们定义一个路由就要在 route 目录下写一行 Route::get(…),控制器一多,路由文件就变得又臭又长。TP8.0 带来了注解路由的完善支持,直接在控制器方法上写一行 @Route 注释,路由就自动绑定。再配合模型事件,很多重复的字段处理也能丢给模型自动完成。今天我就用一个小小的文章发布功能,演示一下这对组合有多爽。
环境准备
你至少需要 ThinkPHP 8.0 或更高版本,并且 PHP 版本要 >= 8.0。建议是 8.1。创建项目我默认你已经会了,直接安装:
composer create-project topthink/think tp8-demo
然后开启注解路由支持,在 config/route.php 里把 controller_suffix 设置为 true,并且确保 with_route 是打开的。你也可以完全依赖注解,不用再手动写动态路由。
注解路由怎么写
打开一个控制器,比如 app/controller/Article.php,在方法上面加一行 #[Route('hello')]。这里 TP8 用的是 PHP 原生的注解属性,不是 dockblock。新建一个测试方法:
<?php
namespace appcontroller;
use thinkannotationRoute;
class Article
{
#[Route('index')]
public function index()
{
return json(['code' => 0, 'msg' => 'ok']);
}
}
注意看,我没有在 route.php 里写任何东西,浏览器访问 /article/index.html 就能访问到。因为 ThinkPHP 的默认路由规则是 控制器/方法,当你用注解时,它其实是在补充一条显式路由。如果你想自定义路径,可以这样:
#[Route('article_list')]
public function list()
{
return json(['code' => 0, 'msg' => 'list']);
}
这样访问 /article_list.html 就会执行这个 list 方法。是不是有点像 Laravel 的。但如果你觉得写注解还是麻烦,TP8 还支持分组注解:
#[RouteGroup('api')]
class Article
{
#[Route('index')]
public function index() {}
}
这会把类下所有方法的路径前缀加上 api,访问 /api/article/index.html。注意这里有一个坑:控制器类名会变成路由名,所以 index 方法最终路径是 /api/article/index,除非你在方法上用了一个带前缀的路由。其实 TP8 的注解路由已经非常接近 Laravel 的 route 注解了,只是参数写法稍有差异。
真实案例:文章发布
现在做一个小功能:用户提交文章标题和内容,后端写入数据库,写入后自动给标题加工一下,变成“【推荐】标题”。如果用传统写法,你需要在控制器里写很多业务代码。现在我们把控制器变干净,让模型事件来处理。
先创建数据库表 article,字段有 id, title, content, create_time。
CREATE TABLE article (
id int unsigned auto_increment primary key,
title varchar(255) not null,
content text not null,
create_time int unsigned not null
);
创建一个模型
在 app/model 目录新建 Article.php:
<?php
namespace appmodel;
use thinkModel;
class Article extends Model
{
// 关闭自动时间戳,我们手动处理
protected $autoWriteTimestamp = false;
// 模型事件:写入前触发(文章标题自动加推荐前缀)
public static function onBeforeInsert($article)
{
// 如果标题没有“推荐”字样,加上
if (strpos($article->title, '【推荐】') === false) {
$article->title = '【推荐】' . $article->title;
}
}
// 模型事件:写入后触发(记录日志,或者做其他事)
public static function onAfterInsert($article)
{
// 可以在这里写日志,比如写入到 log 表
// 别用 print_r,直接记录到文件或走日志通道
trace('新文章入库,ID:' . $article->id . ',标题:' . $article->title);
}
}
ThinkPHP 的模型事件有很多,比如 onBeforeInsert、onAfterInsert、onBeforeUpdate,它们会在对应的数据库操作时自动调用。我们这里用 onBeforeInsert 修改标题,onAfterInsert 记录一条日志。
控制器变得非常干净
在 app/controller/Article.php 中写一个保存方法:
<?php
namespace appcontroller;
use thinkannotationRoute;
use appmodelArticle as ArticleModel;
class Article
{
#[Route('save', method: 'POST')]
public function save()
{
$article = new ArticleModel();
$article->title = input('title');
$article->content = input('content');
$article->create_time = time();
$article->save(); // 触发模型事件
return json(['code' => 0, 'msg' => '发布成功', 'data' => $article]);
}
#[Route('read/:id', method: 'GET')]
public function read($id)
{
$article = ArticleModel::find($id);
if (!$article) {
return json(['code' => 1, 'msg' => '文章不存在']);
}
return json(['code' => 0, 'data' => $article]);
}
}
注意这里我把路由方法改成 POST 和 GET 分开,注解写法是 method: 'POST'。这样前端可以通过 /article/save.html 来 POST 提交。在默认路由下,如果你不写注解,直接用 /article/save.html 也能 POST 到,但是用了注解路由后,它就成了一个显式的路由,并且会限制方法。
完整流程走一遍
我们先用 Postman 模拟 POST 请求,提交 title 和 content,内容随便写“今天学习了 ThinkPHP8”。
POST /article/save.html
title=今日学习随笔
content=今天搞明白了注解路由和模型事件
发出去后,返回:
{
"code": 0,
"msg": "发布成功",
"data": {
"id": 1,
"title": "【推荐】今日学习随笔",
"content": "今天搞明白了注解路由和模型事件",
"create_time": 1732322222
}
}
请注意 title 字段,被模型事件自动加了“【推荐】”前缀,你根本没有在控制器里改这个字符串。这就是模型钩子的价值:当你要给一些字段做统一预处理时,直接在模型里挂事件,而不是在每个控制器里写重复代码。以后哪怕换成命令行脚本,也会自动调用这个事件,万无一失。
onAfterInsert 到底干了啥
上面的 trace 已经写入了日志。你可以打开 runtime/log 目录下最新的日志文件看看,里面会有一条 新文章入库,ID:1,标题:【推荐】今日学习随笔。这就是 onAfterInsert 被触发的结果。实际上你可以在 onAfterInsert 里发通知、写队列、同步到搜索引擎,总之不用弄脏控制器。
把模型事件用到歪门邪道上
比如有的系统需要把文章标题的首字母提取出来作为索引,传统做法是控制器里写一段函数。现在你可以直接内置在模型里:
public static function onBeforeInsert($article)
{
// 生成标题首字母
$article->letter = strtoupper(substr($article->title, 0, 1));
// 如果标题内容包含敏感词,直接抛异常,写入失败
if (strpos($article->content, '违法') !== false) {
throw new Exception('内容包含敏感词,禁止发布');
}
}
这里注意,如果你在事件里抛异常,save() 会直接失败,这样可以统一做数据校验。但我不推荐把复杂校验放事件里,还是用验证器更合适。不过简单规则完全可行。
注解路由的另外一些玩法
除了方法级路由,还有资源路由。TP8 支持用 #[Resource('article')] 自动生成 RESTful 方法列表。你可以这样写:
<?php
namespace appcontroller;
use thinkannotationRoute;
use appmodelArticle as ArticleModel;
#[RouteGroup('api')]
class Article
{
#[Route('resource', method: 'GET')]
public function index() // 列表
{
$list = ArticleModel::order('id desc')->select();
return json($list);
}
#[Route('resource/create', method: 'GET')]
public function create() {}
#[Route('resource/:id', method: 'GET')]
public function read($id) {}
#[Route('resource/:id/edit', method: 'GET')]
public function edit($id) {}
#[Route('resource', method: 'POST')]
public function save() {}
#[Route('resource/:id', method: 'PUT')]
public function update($id) {}
#[Route('resource/:id', method: 'DELETE')]
public function delete($id) {}
}
然后你就能用标准的 RESTful 路径来访问了。POST /api/article/resource 就是新增,DELETE /api/article/resource/1 是删除。这比手动写一堆路由规则要清晰得多。
踩坑记录
第一,注解路由需要安装 topthink/think-annotation 扩展包。好在 TP8 默认已经装好了。如果你是在旧项目上升级,记得 composer require topthink/think-annotation。
第二,解析注解需要缓存,所以部署后如果改了注解,记得清理 runtime 下的缓存文件,否则注解不生效。
第三,路由匹配顺序问题。如果你用了分组注解,又用了方法上的子路由,相同路径时 TP8 会优先匹配有路由注解的方法。但当你有多个方法都匹配同一个 URL 时,TP8 大概率会报冲突,你必须规范好路径,不要有两个方法访问同一个 URL 但没有唯一路由。
第四,POST 请求时,引入参数需要用 input() 而不是 $_POST,因为 TP8 做了过滤。但如果你用了注解路由,input() 是推荐方式,因为它会自动处理不同 content-type。
模型事件和注解路由合体后的效果
以前写一个文章模块,需要准备:路由文件(至少 7 行)、控制器存数据并处理标题前缀、数据库插入。现在只需要控制器里三行代码,模型帮你做预处理,路由只要一行注解。最大的收益是:当你有好几个地方创建文章(比如后台、微信小程序等),这些入口调用的都是同一个模型,那么所有写入操作都会自动遵循“标题加【推荐】”这个规则。
如果你觉得模型事件还是有点“隐式”不太好 debug,你可以在事件里打断点或者输出到专用日志,但最好不要输出到页面。其实 ThinkPHP 的事件机制就是观察者模式,你习惯了就会觉得很自然。
总结
今天通过一个文章发布功能,把 TP8 注解路由和模型事件串了起来。注解路由能减少路由文件的繁杂,模型事件能让数据逻辑内聚到模型里,而不是控制器。这两者结合,你甚至会感觉到控制器变得十分“空”,但功能一点都没少。这是一种很好的开发体验。
如果你的项目还没有升级到 TP8,强烈建议试一下。在官方文档里,注解路由和模型事件都有详细说明,但很多示例都是割裂的。把它们放在一个真实的场景里,你才能感受到威力。
下次再有人问你怎么用 TP8,你可以直接丢这篇文章给他。如果想继续深入,后面可以聊聊模型搜索器和访问器的配合,那也很有趣。

