简介这是一套基于ThinkPHP框架开发的EasyCMS开源内容管理系统完整源码面向需要快速搭建企业站、资讯门户或行业网站的技术人员也适合希望学习CMS二次开发的PHP初学者。资源共1437个文件压缩包约20.17MB其中417个PHP文件承载系统核心业务逻辑99个HTML、54个CSS、97个JS文件组成前端展示与交互层另有386个GIF、217个PNG、87个JPG等图标素材以及少量配置、SQL安装脚本和授权说明。后台界面采用DWZ框架整体目录结构清晰安装部署后即可直接进入内容管理流程。截至目前已有350人学习下载。通过阅读源码可以掌握ThinkPHP框架的模块化组织方式、后台权限与内容发布机制的常见实现思路也能参考其模板渲染与静态资源调度方法对自主开发CMS或定制企业站点都有直接帮助。1. easycms 这套 ThinkPHP 开源 CMS 设计源码到底在解决什么问题easycms 这套基于 ThinkPHP 的开源 CMS 设计源码本质上是在回答一个很现实的问题内容发布这件事对中小站点来说到底该做到多复杂你翻开一个几千条数据的企业官网要的通常只是分类管理、文章发布、栏目页和详情页、后台权限、模板切换这几件事而大而全的内容平台反而让人不敢碰。用 ThinkPHP 自己搭一套轻量 CMS正好卡在这个需求中间既能完全控制路由、模型、缓存和权限又不用背上几十张表和一堆用不上的插件。这篇文章按实际可复现的方案拆解整套设计源码该有的结构把核心逻辑、表设计、代码和坑都摆出来适合准备自己动手做信息站、博客或企业内容后台的开发者。2. easycms 的架构与数据模型先立好表结构后面业务才不会翻车2.1 模块分层与路由映射为什么把 admin、index、api 分开很多人拿到 ThinkPHP 的第一反应是直接在同一个模块里写前台和后台最后发现控制器越堆越乱权限边界模糊模板也互相干扰。easycms 这类内容系统的常见做法是把应用拆成多个模块后台管内容录入、前台管展示、公共部分放模型和工具接口部分单独留出来。这样做的直接好处是你可以在后台模块里放心写带管理员鉴权的逻辑不用每次判断请求来自哪儿前台和后台的控制器方法也不会因为同名而冲突。app/ ├── admin/ # 后台模块登录、分类管理、文章管理、用户管理 ├── index/ # 前台模块首页、栏目页、文章详情、标签页 ├── api/ # 接口模块给小程序或 App 预留 └── common/ # 公共模块模型、服务层、上传与工具类这个目录结构在 ThinkPHP 6 之上需要开启多应用模式。常见做法是先用composer安装多应用扩展再在config/app.php里把auto_multi_app打开这样访问admin、index这些目录就会自动映射到对应模块。composer require topthink/think-multi-app// config/app.php return [ // 默认应用模块 default_app index, // 自动多应用模式 auto_multi_app true, ];参数说明default_app决定用户直接访问域名时落到哪个模块easycms 里通常指向indexauto_multi_app打开后URL 里的第一段/admin、/api就直接作为模块名。这里有个容易忽略的细节如果你用 Nginx 做了伪静态必须把index.php隐藏规则里对/admin、/api的路径放行否则后台入口会被静态资源规则吞掉。分层之后模型统一放在common模块的model目录里这样index和admin两个控制器可以使用同一套模型方法。比如文章状态过滤、分类路径查询这类业务逻辑写在模型层前台只调方法后台也只调方法避免了两个模块各自复制一段 SQL 的维护噩梦。我在实际做 easycms 这类项目时会把controller写得尽量薄把列表查询、详情查询、访问量更新这类逻辑全部下沉到模型或服务类里这样前后台的差异只在参数输入和返回方式上不会出现同一套内容规则在两处实现不一致的情况。2.2 分类、文章、标签的表结构设计与索引策略内容系统的表设计不要一上来就按最大想象去建要按发布场景反推。easycms 最核心的是分类和文章两张表分类表既要支持两级菜单又要兼顾未来可能的无限层级所以保留一个parent_id字段就够了。文章表则要把标题、封面、摘要、状态、排序、发布时间这些高频过滤字段单独拆出来长内容放content不要跟列表查询混在一起。CREATE TABLE tp_category ( id int unsigned NOT NULL AUTO_INCREMENT, parent_id int unsigned NOT NULL DEFAULT 0 COMMENT 父级分类ID0表示顶级, name varchar(64) NOT NULL DEFAULT COMMENT 分类名称, alias varchar(64) NOT NULL DEFAULT COMMENT URL别名用于伪静态, sort int NOT NULL DEFAULT 0 COMMENT 排序权重数值越大越靠前, status tinyint NOT NULL DEFAULT 1 COMMENT 0隐藏 1显示, PRIMARY KEY (id), KEY idx_parent_sort (parent_id, sort) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENTeasycms分类表;parent_id和sort建联合索引是因为前台展示分类栏目时最常见的查询是where parent_id 0 order by sort desc这个索引可以同时覆盖筛选和排序。这里不建议把所有分类一次性读出来再用 PHP 层层递归因为栏目数量较少时可以但等分类涨到几千个递归的查询次数和内存占用会让你后悔。用一条order by parent_id, sort查出来在 PHP 侧组装成树形数组是更可控的做法。CREATE TABLE tp_article ( id int unsigned NOT NULL AUTO_INCREMENT, category_id int unsigned NOT NULL DEFAULT 0 COMMENT 所属分类ID, author_id int unsigned NOT NULL DEFAULT 0 COMMENT 后台用户ID, title varchar(200) NOT NULL DEFAULT COMMENT 文章标题, slug varchar(200) NOT NULL DEFAULT COMMENT URL别名用于伪静态, cover varchar(255) NOT NULL DEFAULT COMMENT 封面图路径, content longtext COMMENT 文章正文, keywords varchar(200) NOT NULL DEFAULT COMMENT SEO关键词, description varchar(500) NOT NULL DEFAULT COMMENT SEO描述, status tinyint NOT NULL DEFAULT 0 COMMENT 0草稿 1发布 2回收站, is_top tinyint NOT NULL DEFAULT 0 COMMENT 是否置顶 0否 1是, click int unsigned NOT NULL DEFAULT 0 COMMENT 点击量, published_at datetime DEFAULT NULL COMMENT 发布时间, created_at datetime DEFAULT NULL, updated_at datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_category_status (category_id, status), KEY idx_top_published (is_top, published_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENTeasycms文章表;idx_category_status是最常用的列表索引。前台栏目页的查询条件通常是category_id and status 1这个复合索引能直接命中两列不用回表再过滤。idx_top_published则解决列表页置顶排序问题先按置顶字段倒序再按发布时间倒序。内容站的时间一长published_at上的普通单列索引也会被用到但把它和is_top做成联合对统计页和后台列表都有好处。标签表采用标准的多对多设计主表只存标签名和别名关联表存文章与标签的映射。注意关联表的两个外键都建单列索引因为你会同时按 tag_id 查文章列表、按 article_id 查标签集合两个方向都有可能成为索引起点。CREATE TABLE tp_tag ( id int unsigned NOT NULL AUTO_INCREMENT, name varchar(64) NOT NULL DEFAULT COMMENT 标签名, alias varchar(64) NOT NULL DEFAULT COMMENT 供伪静态路径使用, PRIMARY KEY (id), KEY idx_alias (alias) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENTeasycms标签表; CREATE TABLE tp_article_tag ( article_id int unsigned NOT NULL, tag_id int unsigned NOT NULL, PRIMARY KEY (article_id, tag_id), KEY idx_tag (tag_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENTeasycms文章标签关联表;这样设计后文章模型里可以直接用hasMany或belongsToMany做关联。我用的是belongsToMany关联表名填tp_article_tag模型会自动维护中间表数据。在输出文章详情时通过预加载-with把标签带出来避免 N1 查询。很多刚上手的人会把标签直接塞进文章表一个 text 字段里省事是省事但后台上千篇文章要按标签筛选时一条like %标签%全表扫描能把数据库拖垮所以标签关联表这笔投入非常值得。3. 用 ThinkPHP 把 easycms 核心跑起来最小命令与按需查询3.1 创建应用、连接数据库和第一段可访问的页面拿到 ThinkPHP 源码包之后第一步不是立刻写控制器而是把项目初始化跑通。常见做法是用 Composer 创建项目骨架再引入多应用扩展。这里有个取舍如果你只打算做一个单模块的单页小工具不用装多应用扩展但 easycms 既然有前台后台和接口多应用模块是少不了的。composer create-project topthink/think easycms-tp cd easycms-tp composer require topthink/think-multi-app php think run命令说明composer create-project会从镜像拉取 ThinkPHP 基础骨架生成的是精简目录php think run是本地开发调试服务器的启动命令默认监听 8000 端口浏览器访问http://127.0.0.1:8000看到欢迎页就说明环境没问题。生产环境不要用这个命令要配置 Nginx 或 Apache 指向public目录。数据库连接配置放在.env文件里不要写在database.php里提交到 Git。.env可以设置不同环境的不同数据库持续部署时也方便替换。APP_DEBUG true DATABASE_TYPE mysql DATABASE_HOSTNAME 127.0.0.1 DATABASE_DATABASE easycms DATABASE_USERNAME root DATABASE_PASSWORD 你的密码 DATABASE_HOSTPORT 3306 DATABASE_CHARSET utf8mb4 DATABASE_PREFIX tp_参数说明DATABASE_PREFIX值得尽早统一。easycms 这类开源设计源码一般把表前缀固定成tp_这样将来二次开发或迁移时同库里的其他业务表不会跟 CMS 表冲突。开发阶段要记得把APP_DEBUG打开能直接看到 SQL 日志和异常信息上线前必须关掉否则 ThinkPHP 会把完整报错路径和 SQL 语句暴露给访问者。初始化完成后用命令生成后台和前台需要的控制器。不同版本的 ThinkPHP 命令参数略有差异通用做法是指定应用模块名php think make:controller index/Article php think make:controller admin/Article php think make:model common/Article这里的make:model指定了common命名空间生成的模型类放在app/common/model/Article.php。生成后在模型里配置表名和关联这样后续控制器里直接调用即可。?php namespace app\common\model; use think\Model; class Article extends Model { // 表名是自动解析的这里是显式声明避免多模块混用歧义 protected $name article; }注意protected $name填的是去掉前缀后的表名ThinkPHP 会自动拼接tp_article。如果你的类名和表名都能对得上可以省略这行但显式写在容易被人接手看的 CMS 代码里更清晰。3.2 文章列表和详情用查询器而不是手拼 SQLCMS 的列表页和详情页是最核心的查询场景。很多人喜欢直接Db::query(select * from tp_article where ...)这种写法的最大问题是缓存条件难加、SQL 注入风险靠拼接时人肉防御而且代码里混着原生 SQL以后想切换数据库也麻烦。在 easycms 里我会尽量用 ThinkPHP 的查询器组合条件。?php namespace app\index\controller; use app\common\model\Article; use think\facade\Db; class ArticleController { // 列表页按分类、状态、置顶和发布时间过滤 public function index() { $list Article::where(status, 1) -with([category]) -field(id, title, cover, description, published_at) -order(is_top, desc) -order(published_at, desc) -paginate(10); return view(article/index, [ list $list, page $list-render() ]); } // 详情页必须带 status 过滤草稿不能被外部访问到 public function detail($id) { $detail Article::where(status, 1) -where(id, $id) -find(); if (!$detail) { abort(404, 文章不存在或已删除); } // 点击量 1这里故意不加锁让并发时丢几次计数也在容错范围内 Db::name(article)-where(id, $id)-exp(click, click1)-update(); return view(article/detail, [detail $detail]); } }逻辑说明where(status, 1)必须在列表和详情的起点就写上它是前台永不越过的底线条件防止将来有人构造detail?_id草稿ID直接访问。with([category])是预加载会把这一页文章对应的分类一次性IN查询出来避免每篇文章查一次分类表。paginate(10)返回的分页对象还自带render()输出分页链接这里传给模板以后前台翻页就成了带参的跳转。exp(click, click1)的作用是把当前字段量最终拼成 SQL 里的click click 1而不是先 select 再 update这样点击量统计不用读改写三步。注意exp的值是 SQL 表达式所以只能填固定字符串绝不能拼接用户输入。如果你使用的高版本查询器支持inc方法也可以写成-inc(click)-update()效果类似。这里还有一个在 easycms 设计里容易被忽略的问题分页参数被整页缓存污染。如果列表页做了全页缓存第二页、第三页的 URL 参数包含page2缓存 key 就必须把 page 带进去。在写控制器时我习惯让分页对象作为模板变量而不是在模板里手动拼page参数这样分页链接会保留当前查询条件比如分类 ID、标签名。否则用户在第三页翻到最后一页再点下一页容易丢失条件导致返回错误列表。4. 后台权限与前台模板easycms 能上线运营的关键模块4.1 RBAC 三张核心表与中间件鉴权easycms 的后台不能只是一个能编辑文章的页面还要让不同角色的管理员有不同的操作范围。RBAC 是权限设计的常见做法核心是用户、角色、权限三张主表加上用户与角色、角色与权限两张关联表。用户表直接用后台管理员表角色表保存角色名和标识权限表保存具体的控制器/方法或 URL 路径。CREATE TABLE tp_admin ( id int unsigned NOT NULL AUTO_INCREMENT, username varchar(64) NOT NULL DEFAULT COMMENT 登录名, password varchar(255) NOT NULL DEFAULT COMMENT 密码哈希, nickname varchar(64) NOT NULL DEFAULT , status tinyint NOT NULL DEFAULT 1 COMMENT 0禁用 1正常, last_login_at datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENTeasycms后台管理员表; CREATE TABLE tp_role ( id int unsigned NOT NULL AUTO_INCREMENT, name varchar(64) NOT NULL DEFAULT COMMENT 角色名称, remark varchar(255) NOT NULL DEFAULT COMMENT 角色说明, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENTeasycms角色表; CREATE TABLE tp_permission ( id int unsigned NOT NULL AUTO_INCREMENT, name varchar(64) NOT NULL DEFAULT COMMENT 权限名称, method varchar(32) NOT NULL DEFAULT COMMENT 请求方法或控制器路径, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENTeasycms权限点表;关联表通过外键表名来识别常见命名是tp_admin_role和tp_role_permission。ThinkPHP 模型定义belongsToMany关联时会自动识别中间表的字段名admin_id、role_id、permission_id。如果不习惯自动规则可以在控制器里手动查出角色列表再逐条比对但代码量会变大。最重要的不是建表而是让每次请求都经过鉴权中间件。easycms 后台模块的所有路由都挂在一个AdminAuth中间件上。中间件在请求进入控制器之前先取当前登录用户再通过用户找角色再比对角色权限判断请求路径是否在权限集合里。?php namespace app\admin\middleware; use think\facade\Session; use think\facade\Db; class AdminAuth { public function handle($request, \Closure $next) { // 取不到后台登录用户就跳转登录页 $adminId Session::get(admin_id); if (!$adminId) { return redirect((string) url(/admin/login/index)); } // 路径格式统一为 admin/controller/action用于权限比对 $path $request-pathinfo(); $user Db::name(admin)-find($adminId); if (!$user || $user[status] ! 1) { Session::delete(admin_id); return redirect((string) url(/admin/login/index)); } // 超级管理员不走权限表直接放行 $roles Db::name(admin_role)-alias(ar) -join(role r, r.id ar.role_id) -where(ar.admin_id, $adminId) -select(); foreach ($roles as $role) { if ($role[name] 超级管理员) { return $next($request); } } $permissionCount Db::name(role_permission)-alias(rp) -join(permission p, p.id rp.permission_id) -where(rp.role_id, in, array_column($roles-toArray(), id)) -where(p.method, $path) -count(); if ($permissionCount 0) { return $next($request); } abort(403, 没有该操作的权限); } }逻辑说明这个中间件把路径直接当作权限标识简单直接适合内容型 CMS。需要注意$request-pathinfo()拿到的路径包含admin模块名而权限表里的method字段也要存同样的格式比如admin/article/delete。为了让权限点更清晰我会在权限表里保存两条粒度一条是控制器路径一条是具体方法。比如admin/article表示整个文章管理菜单可见admin/article/delete表示删除按钮可操作前端模板可以用同一个权限集合判断按钮是否渲染。中间件的注册可以在app/admin/middleware.php里写好。所有后台路由都需要鉴权除了登录和退出这两个公开方法。你可以在路由组定义时单独排除也可以在控制器方法里做个白名单判断。我一般会专门建一个admin.php文件集中定义不带AdminAuth的路由包括登录、验证码、退出避免在白名单列表里频繁增删。4.2 模板变量分配与自定义标签让设计稿能落到页面后台把数据存进去之后前台要把数据渲染出来。ThinkPHP 内置模板引擎默认能输出$list和$detail这些变量但在 CMS 场景里还需要一些公共能力侧边栏分类、最新文章、热门标签、SEO 设置。如果每个控制器都去查一遍公共数据再assign会非常啰嗦。简单可靠的做法是定义一个 ViewBase 控制器在initialize里把公共数据一次性赋值。?php namespace app\index\controller; use think\Controller; use app\common\model\Category; use app\common\model\Tag; use app\common\model\Article; class BaseController extends Controller { protected function initialize() { parent::initialize(); // 顶部导航只显示状态正常的分类 $categories Category::where(status, 1) -order(sort, desc) -select(); $this-view-assign(site_categories, $categories); // 侧栏热门文章按点击量取前 8 条 $hotArticles Article::where(status, 1) -field(id, title, click, published_at) -order(click, desc) -limit(8) -select(); $this-view-assign(site_hot_articles, $hotArticles); // 通用标签 $tags Tag::order(id, desc)-limit(20)-select(); $this-view-assign(site_tags, $tags); } }模板里最常见的做法是循环数组并生成 URL。为了不把路径规则散落到各个模板我会用 ThinkPHP 的url函数统一生成。比如分类别名alias、文章别名slug生成伪静态路径。a href{:url(/category/ . $value[alias])}{$value.name}/a a href{:url(/article/ . $value[slug])}{$value.title}/a注意这里$value[slug]是文章别名如果数据库里没有维护slug也可以直接用id替代但要保证 URL 规则统一。另一个隐患是分类别名和文章别名可能重复如果命名规则不一致伪静态路由会出现二义性。我的习惯是给分类别名加前缀cat-文章 slug 使用post-这样同库只能有一个匹配点排查路由问题也更省事。当需要自定义模板标签时比如{:article_list(5)}这样的快捷签可以用 ThinkPHP 的模板驱动注册自定义函数。常见做法是定义助手函数后再绑定到模板引擎。// config/template.php tpl_replace_string [ __STATIC__ /static, ],// app/common.php function article_list($num 5, $categoryId 0) { return \app\common\model\Article::where(status, 1) -when($categoryId 0, function ($query) use ($categoryId) { $query-where(category_id, $categoryId); }) -field(id, title, slug, cover, published_at) -order(published_at, desc) -limit($num) -select(); }这样模板里直接{:article_list(5)}就能输出五个最新文章标题。注意自定义函数要防止过度查询每次模板渲染都会调用一次如果页面有几十个调用点列表页的数据库压力会明显变大。我在实际项目里会只对公共数据做一次查询其他小列表全部走缓存。5. easycms 上线前避坑指南路由、上传、缓存与安全5.1 路由坑分类与文章 URL 相互吞参数现象分类栏目页正常打开但进入文章详情页时 URL 重写规则偶发失效出现404或者跳到首页用php think route:list查看路由才发现详情路由被前面的分类规则匹配走了。原因ThinkPHP 多应用下路由规则优先级按照定义顺序执行。如果你先定义了category/:alias后定义article/:slug当:slug恰好也满足分类别名格式时就会被分类路由吞掉。:通配符默认不是强制的边界它可以把多段路径也折进去导致后面的规则永远匹配不到。解决把文章详情路由放在分类规则之前并对参数加上正则约束。在路由定义文件里使用Route::pattern或直接在规则里写:slug限定字符集。// route/app.php Route::pattern(slug, [a-zA-Z0-9\-]); Route::get(article/:slug, index/article/detail); Route::get(category/:alias, index/category/index);这样alias和slug都限制为字母数字加中划线不会出现中文路径匹配混乱。同时路由顺序上更精确的article放前category放后避免吞并。上线前用一段脚本把站内所有链接都跑一遍爬虫检查能提前发现这类问题。5.2 上传坑只校验后缀导致服务器被执行现象上传图片接口被当成文件上传入口有人把.php或.phtml文件伪装成.jpg上传成功再通过访问上传目录执行恶意代码。原因只检查了文件后缀名没有检查 MIME 类型和文件内容头。部分服务器配置不当让public/upload目录下的 PHP 文件也被执行等于给攻击者开了一个后门。解决easycms 这类 CMS 里上传目录必须禁止执行 PHP 脚本同时做多重校验。先用 ThinkPHP 的validate规则校验后缀再用finfo检查文件头判断真实类型。$file $request-file(image); $file-validate([ext jpg,jpeg,png,gif,webp], 文件格式不正确) -move(public_path(uploads/images), $fileName);但更重要的一层是 nginx 配置中对上传目录的 disable 设置。如果没有服务器权限则应在生成文件名时把解析器敏感字符全部过滤掉比如文件后缀统一转小写、去掉空格。最终评判标准是上传目录返回的文件必须是image/jpeg等类型且即使强行访问.php文件也是直接下载而不是执行。5.3 缓存坑文章发布后首页和栏目页不更新现象后台点击发布文章前台首页仍然是旧内容强制刷新也没变化过几分钟后再访问才正常。原因easycms 列表页做了整页缓存或数据缓存但没有在文章写入时清除相关缓存。ThinkPHP 的缓存驱动如果是 redis缓存 key 可能包含了排序参数page不同 key 不同导致部分页面更新而部分没更新。解决在文章模型的afterInsert、afterUpdate事件里统一做缓存清理不要只在控制器里手动清理。把首页、栏目页、标签页的缓存 key 列出并delete或者让缓存 key 带一个version标记每次改动文章后递增版本号。namespace app\common\model; use think\facade\Cache; class Article extends Model { public static function onAfterInsert($article) { self::clearRelatedCache(); } public static function onAfterUpdate($article) { self::clearRelatedCache(); } protected static function clearRelatedCache() { Cache::delete(cms_index_news); Cache::delete(cms_categories_sorted); } }参数说明这里的缓存 key 是随便举例实际项目里应该统一维护一个缓存清单。最稳妥的做法是缓存 key 末尾都带一个cms_version值用Cache::set(cms_version, time())更新读取时拼上版本号。这样改完文章只需要删一个小 key旧缓存自然失效逻辑也简单。5.4 注入坑手拼条件字符串带来的删除事故现象后台文章列表有个按标题搜索的输入框填入%或_后返回了大量无关数据更严重的在删除操作里用字符串拼接分类 ID导致删错了整批文章。原因where条件直接用字符串拼进去把用户输入当成了 SQL 片段%会被数据库当成 LIKE 通配符实际查成了所有记录。这类问题在后台管理里特别隐蔽因为开发时往往只测精确匹配。解决查询条件一律使用数组或预编译参数。ThinkPHP 查询器的where支持数组模式能自动绑定参数。比如搜索标题时用like匹配也要把用户输入作为参数绑定而不是直接拼接。$search $request-param(keyword, ); $list Article::where(title, like, % . $search . %) -where(status, , 2) -paginate(10);注意这里的% . $search . %值本身作为参数传入数据库会把$search当字面量处理不会因为%或_而变成通配符以外的语法注入点。所以方法是对的只是要注意最终执行时用的是参数绑定ThinkPHP 会自动转义字符串不要人为再拼一层字符串。5.5 调试坑上线后忘了关调试模式页面直接打印 SQL现象用户访问某个报错页时浏览器里出现了完整的 SQL 语句、表结构和文件路径包含数据库表前缀和应用目录绝对路径。原因.env里APP_DEBUG保持为trueThinkPHP 会把异常堆栈转成可读页面对攻击者来说这是免费的情报收集。解决上线前把.env的APP_DEBUG设为false并额外检查runtime目录是否有写入权限错误。同时把错误日志单独输出到/runtime/log不输出到响应。在我的习惯里vscode 的文件跟踪和 git diff 范围内一定要检查.env是否已经被提交不然密钥和密码就该一起发到仓库去了。6. 把 easycms 从能用调到扛得住缓存层级与查询验证内容管理系统真正上生产后最常用的性能验证手段不是靠感觉而是用压测命令直接看请求数。我用得最多的命令是 ApacheBench对一个整理成静态规则的列表页发起并发请求。ab -n 1000 -c 50 -H Accept-Encoding: gzip,deflate http://example.com/category/php参数说明-n 1000表示总请求一千次-c 50表示同时五十个并发这样的强度足够暴露数据库慢查询和缓存失效问题。压测至少看两个指标Requests per second 和 Time per request。若每秒请求数在没缓存时低于一百说明 SQL 或渲染肯定有瓶颈。找到瓶颈后常见做法是先把最外层页面静态化。easycms 的首页和栏目页可以定时生成纯 HTML 文件POST 回写内容时再重新生成。对文章详情页因为访问量大的是少量高频文章我会用一个 Redis 缓存存渲染后的 HTML 片段发布时根据文章 ID 清掉。// 保存文章详情页的整页缓存 public function detail($id) { $key article_detail_ . $id; $content Cache::get($key); if ($content) { return $content; } // 正常查询与渲染 $detail Article::where(status, 1)-with([category])-find($id); if (!$detail) { abort(404); } $content view(article/detail, [detail $detail])-getContent(); // 缓存十分钟如果发布频率高应缩短为文章生命周期 Cache::set($key, $content, 600); return $content; }但这只是缓存层的方案SQL 层才是关键。easycms 列表页最容易出现 N1 问题先把文章列表查出来再在模板里循环取分类名、标签名、作者名。很多人以为加了缓存就没事但缓存过期瞬间所有请求同时打到数据库照样雪崩。我在列表查询里会用with([category,tags])-select()配合数据库 EXPLAIN 验证索引是否被命中。EXPLAIN SELECT * FROM tp_article WHERE category_id 1 AND status 1 ORDER BY is_top DESC, published_at DESC LIMIT 10;执行后观察type列如果是ref或range说明索引用上了如果是ALL就需要检查复合索引顺序是否匹配查询条件。如果rows数量大但Extra里出现了Using filesort说明排序没法直接走索引常见原因是因为ORDER BY is_top DESC, published_at DESC中两个字段方向必须一致索引默认升序所以要让两者都是DESC才能服务排序。这里有个经验如果排序字段太多与其强调索引不如给列表页加 Redis 缓存因为内容站的数据一致性要求没那么严格几分钟内的陈旧浏览完全可接受。最后一个容易翻车的地方是后台文章搜索。用 LIKE 匹配标题时索引大概率失效这时就要限定搜索场景。easycms 的后台本来数据量不大几千条文章直接where(title,like, %keyword%)压力很小但如果到了几十万条就必须考虑引入全文索引或用外部搜索组件否则任何 SQL 调优都救不了。做这套易用的轻量 CMS我最深的体会是表结构一旦定了后续所有业务都在为它打工。与其一开始追求最全的字段不如先把分类、文章、标签、权限这几张核心表设计扎实其余的东西在开发过程中自然会浮现出来。你到了上线阶段再回来改表结构代价要大得多。希望这些基于实际项目沉淀下来的做法能帮到你。本文还有配套的精品资源点击获取