第10章 10.1 ThinkPHP 6 实战 CMS
「上章回顾」 上一章我们折腾完了 Swoole,用协程让 PHP 跑出了「异步特效」——一个请求里同时调用三个接口,不用傻等。但光会异步还不够,我们手里缺一个能快速搭后台的神器。这就好比你学会了炒菜技巧,但还没一套顺手的锅具。今天就给你一套ThinkPHP 6,用它搭 CMS 内容管理系统,就像搭积木一样快。
🎯 开场 3 分钟:为什么要学 ThinkPHP?
场景来了:
你接了个小活儿——给朋友的奶茶店做个后台,管商品、管订单、管会员。纯原生写光数据库增删改查就得三四天,还不算登录权限那套。朋友催得紧,你熬了两晚发现:大部分代码都是重复的——增删改查、列表分页、表单验证,这些套路每张表都差不多。
痛点就两个:
- 重复劳动太多——写 10 张表的后台,每张都要写「列表 + 新增 + 编辑 + 删除」,烦不烦?
- 想加点公共功能(比如权限控制、操作日志)得改一堆文件,头大不头大?
ThinkPHP 6 就是来解决这个\n\n
\n\n
\n\n的。它帮你把通用的后台功能封装好,你只需要专注于「这张表有哪些字段」「列表要显示什么」——剩下的,自动帮你生成。
学完这章你能:
- 用 ThinkPHP 6 搭建一个带文章管理的 CMS 后台
- 掌握「模型 - 控制器 - 视图」的开发模式
- 独立写出一个能跑起来的「增删改查」后台页面
🧱 基础 25 分钟:核心概念(小白视角)
10.1.1 ThinkPHP 6 是个啥?
类比时间: ThinkPHP 就像一个装修公司提供的「标准套餐」。
- 你想装修房子,可以自己买水泥、沙子、瓷砖慢慢贴(原生 PHP)
- 也可以让装修公司按套餐来——墙纸、地板、门窗都是现成的标准件,只需要告诉它们「我要三室一厅、客厅要浅色地板」(ThinkPHP 规定好的开发模式)
为什么用它?
- 开发快:80% 的后台需求,它已经给你搭好了
- 社区成熟:遇到问题百度一搜一大把
- 文档中文:官网文档全是中文,看得懂
10.1.2 安装 ThinkPHP 6
前置条件: PHP 7.4+(推荐 8.0+),Composer(PHP 的包管理工具)
打开命令行,一行命令搞定:
composer create-project topthink/think cms-demo
解释:这行命令让 Composer 去下载 ThinkPHP 6 的官方项目骨架,在当前目录创建一个叫 cms-demo 的文件夹。
cd cms-demo
php think run
解释:php think run 是 ThinkPHP 内置的调试服务器启动命令。运行后访问 http://localhost:8000,看到欢迎页就说明安装成功了。
10.1.3 目录结构:代码放哪儿?
ThinkPHP 6 的目录很讲究,约定了不同类型的文件放不同地方:
cms-demo/
├── app/
│ ├── controller/ # 控制器:接收请求,调度数据
│ ├── model/ # 模型:操作数据库
│ ├── view/ # 视图:显示页面(HTML)
│ └── ...
├── route/ # 路由:URL 规则
├── public/ # 对外访问的入口
└── ...
类比: 就像一个餐厅分前厅(view)、后厨(controller)、仓库(model)。
10.1.4 第一个页面:Hello ThinkPHP
打开 app/controller/Index.php,改成这样:
<?php
namespace app\controller;
use app\BaseController;
class Index extends BaseController
{
public function index()
{
return "Hello,ThinkPHP 6!我叫小明的奶茶店后台";
}
}
解释:
- namespace app\controller — 声明这个文件属于「控制器」阵营
- class Index — 创建了一个叫 Index 的控制器类
- public function index() — 访问首页时执行的函数
- return "..." — 直接返回一段文字
访问 http://localhost:8000,页面显示:Hello,ThinkPHP 6!我叫小明的奶茶店后台
这就跑起来了,比原生 PHP 少写了一大堆 include 和 echo。
10.1.5 数据库操作:模型登场
光返回文字没用,CMS 得读写数据库。ThinkPHP 6 用模型来操作数据库。
先创建数据表(MySQL):
CREATE TABLE `article` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`title` varchar(255) NOT NULL,
`content` text,
`status` tinyint(1) DEFAULT 1,
`create_time` int(10) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
创建模型 app/model/Article.php:
<?php
namespace app\model;
use think\Model;
class Article extends Model
{
// 指定表名(如果不写,默认用类名小写)
protected $name = 'article';
// 自动写入时间戳
protected $autoWriteTimestamp = true;
}
解释:
- extends Model — 让 Article 类继承 ThinkPHP 的 Model,它自带一堆数据库操作方法
- $name = 'article' — 告诉模型「你要操作的是 article 表」
- $autoWriteTimestamp = true — 自动管理创建时间字段
10.1.6 控制器调用模型
修改 app/controller/Index.php:
<?php
namespace app\controller;
use app\BaseController;
use app\model\Article;
class Index extends BaseController
{
public function index()
{
// 查询所有文章
$list = Article::select();
return json([
'code' => 0,
'msg' => '查询成功',
'data' => $list
]);
}
}
解释:
- Article::select() — 模型自带的方法,查询 article 表所有记录,返回一个集合
- return json([...]) — ThinkPHP 自动把数组转成 JSON 返回
访问 http://localhost:8000,输出:
{"code":0,"msg":"查询成功","data":[]}
data 是空数组,因为表里还没数据。增删改查四件套马上安排。
10.1.7 增删改查四件套
ThinkPHP 6 的增删改查,比写 SQL 语句还简单。在控制器里加几个方法:
<?php
namespace app\controller;
use app\BaseController;
use app\model\Article;
class ArticleController extends BaseController
{
// 文章列表
public function index()
{
$list = Article::order('id', 'desc')->select();
return json(['code' => 0, 'data' => $list]);
}
// 新增文章
public function save()
{
$data = request()->param(); // 获取前端传来的数据
$result = Article::create($data); // 插入数据库
return json(['code' => 0, 'msg' => '新增成功', 'data' => $result]);
}
// 更新文章
public function update()
{
$id = request()->param('id');
$data = request()->param();
$result = Article::update($data, ['id' => $id]);
return json(['code' => 0, 'msg' => '更新成功', 'data' => $result]);
}
// 删除文章
public function delete()
{
$id = request()->param('id');
Article::destroy($id);
return json(['code' => 0, 'msg' => '删除成功']);
}
}
一行一解释:
Article::order('id', 'desc')->select()— 查询并按 ID 倒序排列(最新的在前面)request()->param()— 获取请求参数(GET/POST 都行)Article::create($data)— 创建一条新记录Article::update($data, ['id' => $id])— 根据条件更新记录Article::destroy($id)— 删除指定 ID 的记录
这就是 ThinkPHP 6 的核心——模型帮你封装好了 SQL,你只管调方法。
10.1.8 路由:URL 怎么对应到代码?
刚才写了控制器方法,但浏览器访问哪个 URL 能调到它?这就靠路由。
打开 route/app.php(如果没有就创建),添加:
<?php
use think\facade\Route;
Route::get('article', 'article.ArticleController/index'); // 列表
Route::post('article', 'article.ArticleController/save'); // 新增
Route::put('article/:id', 'article.ArticleController/update'); // 更新
Route::delete('article/:id', 'article.ArticleController/delete'); // 删除
解释:
- Route::get/post/put/delete — 声明 HTTP 方法
- 'article' — URL 路径
- 'article.ArticleController/index' — 对应的「控制器/方法」
现在访问 http://localhost:8000/article,就能调到你写的列表方法了。
🔥 实战 35 分钟:3 个递进的小项目
项目 1:5 分钟搞定文章列表接口(抄改版)
目标: 写一个接口,返回文章列表(带分页)
完整代码:
app/controller/ArticleController.php:
<?php
namespace app\controller;
use app\BaseController;
use app\model\Article;
class ArticleController extends BaseController
{
public function index()
{
// 每页 10 条,获取当前页码(默认第 1 页)
$page = request()->param('page', 1);
$size = 10;
// 计算偏移量
$offset = ($page - 1) * $size;
// 查询总数
$total = Article::count();
// 查询当前页数据
$list = Article::order('id', 'desc')
->limit($offset, $size)
->select();
return json([
'code' => 0,
'msg' => 'success',
'data' => [
'list' => $list,
'total' => $total,
'page' => $page,
'size' => $size
]
]);
}
}
路由 route/app.php:
Route::get('api/article', 'article.ArticleController/index');
预期输出:
访问 http://localhost:8000/api/article?page=1
{
"code": 0,
"msg": "success",
"data": {
"list": [],
"total": 0,
"page": 1,
"size": 10
}
}
一句话解释: 先算偏移量,再查总数,最后查当前页数据——这就是经典的「SQL 偏移分页」。
项目 2:15 分钟完成文章「增删改查」完整功能
目标: 做一个带后台管理页面的文章管理功能(前后端结合)
Step 1:创建视图文件
在 app/view/article/ 目录下创建 index.html:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>文章管理 - CMS 后台</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 10px; text-align: left; }
th { background-color: #4CAF50; color: white; }
.btn { padding: 5px 10px; margin: 2px; cursor: pointer; }
.btn-edit { background-color: #2196F3; color: white; border: none; }
.btn-del { background-color: #f44336; color: white; border: none; }
.form-group { margin: 10px 0; }
input, textarea { padding: 8px; width: 300px; }
</style>
</head>
<body>
<h1>📝 文章管理</h1>
<!-- 新增表单 -->
<div class="form-group">
<input type="text" id="title" placeholder="文章标题">
<textarea id="content" placeholder="文章内容"></textarea>
<button class="btn btn-edit" onclick="saveArticle()">发布文章</button>
</div>
<hr>
<!-- 文章列表 -->
<table>
<thead>
<tr>
<th>ID</th>
<th>标题</th>
<th>内容摘要</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="articleList"></tbody>
</table>
<script>
// 加载文章列表
function loadList() {
fetch('/api/article')
.then(res => res.json())
.then(data => {
let html = '';
data.data.list.forEach(article => {
html += `
<tr>
<td>${article.id}</td>
<td>${article.title}</td>
<td>${article.content ? article.content.substring(0, 50) + '...' : ''}</td>
<td>${new Date(article.create_time * 1000).toLocaleString()}</td>
<td>
<button class="btn btn-del" onclick="deleteArticle(${article.id})">删除</button>
</td>
</tr>
`;
});
document.getElementById('articleList').innerHTML = html || '<tr><td colspan="5">暂无文章</td></tr>';
});
}
// 新增文章
function saveArticle() {
const title = document.getElementById('title').value;
const content = document.getElementById('content').value;
fetch('/api/article', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content })
})
.then(res => res.json())
.then(data => {
alert(data.msg);
if (data.code === 0) {
document.getElementById('title').value = '';
document.getElementById('content').value = '';
loadList();
}
});
}
// 删除文章
function deleteArticle(id) {
if (!confirm('确定删除这篇文章?')) return;
fetch('/api/article/' + id, { method: 'DELETE' })
.then(res => res.json())
.then(data => {
alert(data.msg);
loadList();
});
}
// 页面加载时读取列表
loadList();
</script>
</body>
</html>
Step 2:添加控制器方法
在 ArticleController.php 添加两个方法:
// 渲染后台页面
public function admin()
{
return view('article/index');
}
// API:新增
public function save()
{
$data = request()->param();
$result = Article::create($data);
return json(['code' => 0, 'msg' => '新增成功', 'data' => $result]);
}
// API:删除
public function delete()
{
$id = request()->param('id');
Article::destroy($id);
return json(['code' => 0, 'msg' => '删除成功']);
}
Step 3:配置路由
// 后台页面
Route::get('admin/article', 'article.ArticleController/admin');
// API 接口
Route::post('api/article', 'article.ArticleController/save');
Route::delete('api/article/:id', 'article.ArticleController/delete');
预期输出:
访问 http://localhost:8000/admin/article,你会看到一个带表单的文章管理页面:
- 输入标题和内容,点击「发布文章」会调用 API 新增
- 下方列表显示已发布的文章,每行有「删除」按钮
一句话解释: 控制器负责返回 HTML 页面,前端 JS 调用后端 API 实现「增删」——前后端分离的好处就出来了。
项目 3:15 分钟做一个「数据导入导出」小工具
目标: 把文章数据导出成 CSV,再支持导入 CSV 补充数据
导出 CSV 接口:
// API:导出文章为 CSV
public function export()
{
$list = Article::order('id', 'desc')->select();
// 设置 CSV 表头
$header = ['ID', '标题', '内容', '创建时间'];
// 构建 CSV 内容
$csvData = [];
$csvData[] = $header;
foreach ($list as $article) {
$csvData[] = [
$article->id,
$article->title,
$article->content,
date('Y-m-d H:i:s', $article->create_time)
];
}
// 生成 CSV 字符串
$str = "";
foreach ($csvData as $row) {
$str .= implode(",", $row) . "\n";
}
// 输出文件(浏览器下载)
header('Content-Type: text/csv');
header('Content-Disposition: attachment; filename=article_' . date('Ymd') . '.csv');
echo $str;
exit;
}
导入 CSV 接口:
// API:导入 CSV
public function import()
{
$file = request()->file('file'); // 获取上传的文件
if (!$file) {
return json(['code' => 1, 'msg' => '请选择文件']);
}
// 验证并保存文件
$info = $file->rule('uniqid')->move('./uploads/');
if (!$info) {
return json(['code' => 1, 'msg' => '上传失败']);
}
// 读取 CSV 文件
$filePath = './uploads/' . $info->getFilename();
$handle = fopen($filePath, 'r');
$count = 0;
fgetcsv($handle); // 跳过表头
while (($data = fgetcsv($handle)) !== false) {
// 跳过空行
if (empty($data[0])) continue;
// 批量插入(每 100 条插入一次)
if ($count % 100 == 0) {
$batchData = [];
}
$batchData[] = [
'title' => $data[1],
'content' => $data[2],
'create_time'=> time()
];
if (count($batchData) >= 100) {
(new Article)->insertAll($batchData);
$batchData = [];
}
$count++;
}
// 插入剩余数据
if (!empty($batchData)) {
(new Article)->insertAll($batchData);
}
fclose($handle);
return json(['code' => 0, 'msg' => '导入成功,共导入 ' . $count . ' 条数据']);
}
路由:
Route::get('api/article/export', 'article.ArticleController/export');
Route::post('api/article/import', 'article.ArticleController/import');
预期输出:
导出:http://localhost:8000/api/article/export — 浏览器下载 article_20250626.csv
导入:用 Postman 或前端表单上传 CSV 文件,返回 {"code":0,"msg":"导入成功,共导入 50 条数据"}
一句话解释: 导出用 fputcsv 生成文件流,导入用 fgetcsv 逐行读取再批量写入——CSV 批量处理的核心就这两招。
💪 进阶 20 分钟:常见坑 + 性能小贴士
坑 1:模型名和表名对不上
❌ 错误示例:
// 模型类名叫 Article,但表名叫 "articles"(多了个 s)
class Article extends Model
{
// 没指定表名,默认找 "article" 表,但实际是 "articles"
}
✅ 正确写法:
class Article extends Model
{
protected $name = 'articles'; // 明确指定表名
}
坑来了: 如果表名和模型名不符合 ThinkPHP 的默认规则,必须手动指定,否则查出来的数据是空的。
坑 2:时间戳字段没自动写入
❌ 错误示例:
// 插入数据后发现 create_time 是空的
$data = ['title' => '测试', 'content' => '内容'];
Article::create($data);
✅ 正确写法:
class Article extends Model
{
protected $autoWriteTimestamp = true; // 开启自动时间戳
// 或者指定字段类型
// protected $autoWriteTimestamp = 'datetime';
}
坑来了: ThinkPHP 的自动时间戳只在你声明 $autoWriteTimestamp = true 后才生效,否则不会自动填充。
坑 3:路由不生效?
❌ 错误示例:
路由写了,但访问 404。检查了半天发现——没开启多应用模式。
✅ 正确做法:
ThinkPHP 6 默认是单应用模式,要用多应用(app\controller\article\ArticleController 这种路径),需要安装多应用模式:
composer require topthink/think-multi-app
然后把控制器放到 app/controller/article/ArticleController.php,路由改成:
Route::get('article', 'article/ArticleController/index');
坑 4:数据库连接配置写错了
❌ 错误示例:
改了半天代码,查询一直报错 SQLSTATE[HY000] [1045]。
✅ 检查配置:
打开 config/database.php,检查:
return [
'default' => 'mysql',
'connections' => [
'mysql' => [
'type' => 'mysql',
'hostname' => '127.0.0.1',
'database' => 'cms_demo', // ← 确保数据库名对
'username' => 'root', // ← 确保用户名对
'password' => 'your_password', // ← 确保密码对
'hostport' => '3306',
]
]
];
坑 5:N+1 查询问题
❌ 错误示例:
// 循环查用户信息,100 条记录就查 100 次数据库
foreach ($articles as $article) {
$article->author_name = User::find($article->user_id)->name;
}
✅ 正确写法:预加载:
// 用 with 预加载,一次查询搞定
$list = Article::with('user')->select();
// 模型里定义关联
class Article extends Model
{
public function user()
{
return $this->belongsTo(User::class, 'user_id');
}
}
性能小贴士:批量写入用 insertAll
插入 1000 条数据,别一条一条 create():
// ❌ 慢:1000 次 INSERT
for ($i = 0; $i < 1000; $i++) {
Article::create(['title' => "文章 $i"]);
}
// ✅ 快:1 次 INSERT(批量)
$data = [];
for ($i = 0; $i < 1000; $i++) {
$data[] = ['title' => "文章 $i", 'create_time' => time()];
}
Article::insertAll($data);
调试技巧:打印 SQL 语句
想看看模型实际执行了什么 SQL?
// 方式 1:查看最后一条 SQL
echo Article::getLastSql();
// 方式 2:开启 SQL 日志(config/database.php)
'break_sql' => true, // 遇错断点
// 方式 3:闭包调试
Article::where('id', '>', 0)->select(function($query) {
dump($query->fetchSql()->find());
});
✏️ 练习题 + 作业题(共 7 分钟)
练习题(5 道,10 分钟)
练习 1(2 分钟):改个标题
- 输入:把项目 1 接口的返回示例改成你的名字
- 预期输出:"msg": "你的名字的查询成功"
- 提示:只改 return json() 里的一项
练习 2(2 分钟):加个状态筛选
- 输入:在项目 1 列表接口加一个 status 参数,只返回指定状态的的文章
- 预期输出:传 ?status=1 只返回 status=1 的记录
- 提示:用 where() 方法过滤
练习 3(3 分钟):换数据源
- 输入:把项目 2 的数据表换成「商品表」(自己建一个 product 表,字段自定)
- 预期输出:后台页面能增删商品
- 提示:复制 Article 模型改名为 Product,修改对应的表名和字段
练习 4(5 分钟):串起来
- 输入:用项目 2 的后台新增文章,用项目 3 的导出功能导出 CSV
- 预期输出:导出的 CSV 文件里包含你新增的文章
- 提示:确保路由都配好了,先新增再导出
练习 5(3 分钟):报错分析
- 输入:运行项目时报错 Call to undefined method Article::selectAll()
- 预期输出:分析原因并修复
- 提示:ThinkPHP 模型没有 selectAll 方法,应该用哪个?
作业题(30 分钟 - 2 小时)
作业:做一个「小明的奶茶店库存管理小工具」
需求描述:
帮小明的奶茶店做一个简单的库存管理后台,管三种原料:珍珠、奶盖、茶叶。每种原料有「名称」「库存量」「预警值」(低于这个数就提醒补货)。
功能点:
1. 列表页:显示所有原料及库存状态(正常/需补货)
2. 新增/编辑:修改原料信息和库存量
3. 预警功能:库存低于预警值时,显示红色「⚠️ 需补货」标记
4. 导出:把库存列表导出为 CSV
加分项:
1. 支持按名称搜索
2. 支持批量导入 CSV
验收标准:
- 能跑起来(无报错)
- 列表页正常显示所有原料
- 新增后列表实时更新
- 预警标记逻辑正确(低于预警值显示警告)
📚 总结 + 资源
本文学了 3 件事:
- ThinkPHP 6 的开发模式——模型负责数据库、控制器负责逻辑、视图负责展示,约定大于配置
- 增删改查不用手写 SQL——模型自带方法,一行代码搞定「插删改查」
- 前后端分离做后台——后端提供 API,前端用 HTML/JS 渲染页面,分工清晰
延伸学习资源:
- ThinkPHP 6 官方文档 — 中文文档,讲得很细
- 《ThinkPHP 6 从入门到实践》— 国内作者,写得很接地气
- B站:ThinkPHP 6 实战教程 — 视频教程更适合新手
互动钩子:
「你在实际项目里用过 ThinkPHP 吗?遇到过的最大坑是什么?评论区聊聊,老粉优先回复!」
下章预告:
「学会了 ThinkPHP 6,你已经能快速搭后台了。但如果你想做一个面向移动端的 API 接口(给小程序、App 用),ThinkPHP 就有点大材小用了。下一章我们换 Laravel,用它来搭一套完整的 RESTful API——前后端彻底分离,数据只返回 JSON,接口规范又漂亮。敬请期待!」

评论(0)