PHP 如何设计自定义 CMS?内容模型、分层架构与缓存失效方案
自己写 CMS 的人不少,但很多版本写到中后期都会遇到同样的困境:加一个内容类型要改七八个文件,模板里塞满查询逻辑,改字段就升级失败。问题不在代码量,而在架构分层从一开始就没定。本文给出一套能长期演进的 PHP 内容管理系统架构设计。

一、先定清 CMS 的边界
一个能维护的 CMS 至少要拆成六层,每层只依赖它下面一层:
入口 index.php
└─ Router(URI → Handler)
└─ Controller / Action(参数校验、编排)
├─ Service(业务规则:发布、审核、版本)
│ └─ Repository(数据存取,唯一接触 SQL 的层)
├─ Template Engine(渲染,只做展示)
└─ Event / Hook(扩展点)
└─ 公共设施:Config、DBAL、Cache、Logger、Auth、Queue
三条纪律:模板不查库、Service 不知道 HTTP、SQL 只出现在 Repository。守住这三条,后期换框架、换数据库、做 API 化都不用重写。
二、数据模型:内容类型必须可扩展
CMS 的核心矛盾是“栏目千差万别,但代码要统一”。两种主流建模:
方案 A:主表 + EAV(扩展属性表)
content(id, typeid, title, slug, status, pub_at, author_id, ...) content_field(field_id, content_id, name, value) -- 竖表 field_def(id, typeid, name, type, required, sort)
优点:任意字段无需改表;缺点:查询/排序/统计困难,一次列表页要 JOIN 多个字段。适合字段少变的场景。
方案 B:JSON 列 + 生成的搜索列(现代推荐)
CREATE TABLE content ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, typeid INT UNSIGNED NOT NULL, title VARCHAR(255) NOT NULL, slug VARCHAR(190) NOT NULL, status TINYINT NOT NULL DEFAULT 0, -- 0草稿 1已发布 2待审 3归档 body LONGTEXT, meta JSON NOT NULL, -- 扩展字段全部进这里 price DECIMAL(10,2) GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(meta,'$.price'))) STORED, pub_at DATETIME NULL, FULLTEXT KEY ft_title_body (title, body) WITH PARSER ngram, UNIQUE KEY uk_slug (slug), KEY idx_type_status_pub (typeid, status, pub_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- MySQL 8.0.17+ 可直接建索引:CREATE INDEX idx_price ON content ((CAST(meta->>'$.price' AS DECIMAL(10,2))));
这套兼顾灵活与查询能力,也是当下多数新 CMS 的做法。注意 ngram 全文索引需要设 ngram_token_size=2 才适合中文分词。
另外三张必备表:category(树形,用 parent_id + left_id/right_id 或闭包表 category_closure)、user / role / permission、revision(内容版本,字段同 content 但加 content_id, version)。
三、路由与渲染:URL 规则要能配
CMS 的 URL 是产品的一部分。设计成可配置的规则表而不是硬编码:
// route_rule 表:typeid, pattern, handler, cache_ttl
'/{category}/{slug}' → content@show
'/blog/{year:\d{4}}/{month:\d{2}}' → archive@month
'/tag/{slug}' → taxonomy@show
渲染分两条路径:HTML 走模板引擎(Twig/Blade),数据走 API(同一套 Repository,Controller 只是序列化差异)。这样前端换 Vue/小程序时零成本。
模板体系要支持继承 + 主题覆盖:查找顺序为 主题A/blog/show.html → 基础主题/blog/show.html → 默认。实现方式是模板查找路径数组 + 首个命中,这也是 DedeCMS/Discuz 的做法。
四、缓存:三层,且必须能精确失效
- L1 页面缓存:整页 HTML,按
URL + 语言 + 是否登录作键,存 Redis 或文件,TTL 5~30 分钟。收益最大,风险是脏页。 - L2 对象缓存:栏目树、字段定义、菜单——这些几乎不变,应常驻直到显式失效。
- L3 查询缓存:MySQL 自身 query cache 已废弃,别指望;靠 L1/L2 即可。
关键机制是标签失效(tag invalidation):每个缓存项记下依赖的 content:123、category:7,发布/修改时只删相关标签的缓存。Redis 用 SET 存 tag → keys 映射,或用 Cache::tags()(Laravel 自带,其它框架可自实现约 60 行)。
// 发布文章后
$event->dispatch('content.published', ['id' => 123, 'typeid' => 7]);
// 监听器
$cache->invalidateTags(['content:123', 'category:7', 'list:typeid:7']);
$queue->push('rebuild', ['typeid' => 7]); // 需要静态化的场景
五、扩展点:Hook 而不是改源码
老 CMS 的通病是二次开发靠改核心文件,升级即失效。解决方式是事件/钩子:
final class Hook {
private static array $map = [];
public static function on(string $name, callable $fn, int $priority = 10): void {
self::$map[$name][$priority][] = $fn;
}
public static function fire(string $name, array $ctx = []): array {
$list = self::$map[$name] ?? [];
ksort($list);
foreach ($list as $fns) { foreach ($fns as $fn) { $ctx = $fn($ctx) ?? $ctx; } }
return $ctx;
}
}
// 插件只需注册
Hook::on('content.before_save', function ($ctx) {
$ctx['data']['slug'] ??= slugify($ctx['data']['title']);
return $ctx;
});
约定好稳定的钩子名集合(content.before_save / after_publish / template.render / menu.build / search.index),插件就只依赖这些名字,不依赖实现。这就是 WordPress 的 add_action/apply_filters 之所以长命的核心原因。
六、权限与审核流
CMS 的权限不能只有“能不能进后台”。推荐 RBAC + 内容级作用域:
- 权限点形如
content.edit、content.publish、content.edit.own、category.manage:7; - 角色 → 权限点,用户 → 角色;再加上“范围”维度(全站 / 指定栏目 / 仅自己的稿件),因为编辑往往只能管自己频道;
- 审核流用状态机:
draft → submitted → reviewing → published → archived,非法跳转直接拒绝;每次转换写audit_log(who/when/from/to/ip)。
七、可维护性清单
- 安装与升级:迁移文件按序号(
0001_init.sql、0002_add_meta.sql)+migration_version表记录已执行项;绝不手写“顺便改下数据库”。 - 配置分层:
config/*.php默认值 +.env覆盖 + 数据库可改项(站点名、开关)三层,别把密钥写进代码库。 - 后台与前台分离:入口不同(
/admin走独立中间件与 IP/二次验证),Session 隔离。 - 搜索:数据量 > 百万级就接 Meilisearch/Elasticsearch,用队列异步建索引,MySQL 全文只做兜底。
- 多语言 / 多站点:一开始就决定是加
locale字段还是分库(多租户);后期改造极贵。字段设计里预留site_id。
八、常见问题 FAQ
问:为什么不直接用 WordPress/Discuz?
答:能用就用,自研 CMS 的合理性通常只出现在“数据模型差异极大 + 强合规要求 + 需深度嵌入自有业务”这三种情况。否则你会长期维护一个别人已经做好的轮子。
问:静态化还有必要吗?
答:高并发读、少改动的门户/文章站仍然有效(把 HTML 推到 CDN)。做法是发布事件触发重建任务,配合 try_files 优先返回已存在的静态文件。
问:Service 和 Repository 是不是过度设计?
答:项目只有 5 张表时确实显得重。但只要出现“同一个业务动作要被后台、API、定时任务三处调用”,分层立刻就有价值。
总结架构骨架就一句话:内容类型可扩展、模板不含逻辑、缓存可精确失效、扩展靠钩子、变更靠迁移。这五条守住,CMS 能被不同人接手十年。
生产内容,同时打通搜索引擎(SEO)与AI搜索引擎(GEO)流量入口的软件! 点击进入365建站器 »
