简介:这是一份基于PHP+MySQL实现的MVC架构博客系统源码包,面向Web开发初学者与PHP后端学习者,旨在帮助理解经典三层架构在实际项目中的落地方式。资源包含610个文件,以110个PHP核心逻辑文件(Model/Controller/View分层代码)、151个JS交互脚本、111个PNG图标资源、96个GIF动效素材及52个CSS样式文件为主,辅以HTML/HTM页面模板与少量配置文件(ini/tpl),整体压缩包7.37MB,结构完整,典型MVC目录清晰可辨。已有142人下载学习,适合通过真实博客场景掌握PHP数据库操作(MySQLi/PDO)、路由控制、用户登录、文章增删改查等核心功能。预览可见add_article.html.bak、edit.html.bak等备份文件及多套CSS主题(ui.css、green.css、black.css等),表明项目具备模块化视图切换与开发调试痕迹,便于对照学习代码组织逻辑与前后端协同流程。
1. 用 PHP + MySQL 实现 MVC 架构的博客系统:不是模板套壳,而是真正分层、可维护、能上线的最小可行实践
你在网上搜“php mysql 博客”,十有八九会看到一堆静态页面拼接、SQL 直接写在 HTML 里、用户登录靠 session 硬塞、文章增删改查全在一个index.php里跳转的“伪博客”。这种代码跑得动,但改一个分类逻辑要翻三页,加个评论功能得重写路由,换数据库?先祈祷别崩。而标题里的wangwei.zip_mvc php_php mysql 博客暗示的是一种更严肃的落地路径:它不是教学玩具,而是以标准 MVC 分层为骨架、MySQL 为数据底座、PHP 为执行引擎的真实博客系统雏形——控制器只管流转、模型专注数据契约、视图纯粹渲染,三层之间靠约定而非耦合通信。适合刚脱离 LAMP 原始阶段、正卡在“能写功能”和“能交项目”之间的 PHP 开发者,也适合需要快速验证 MVC 概念、又不想被 Laravel/Lumen 大框架淹没细节的后端新人。它不追求高并发或微服务,但每行代码都经得起git blame和grep -r 'SELECT'的拷问。
2. 为什么选原生 PHP + 手写 MVC 而非框架?从路由调度到请求生命周期的底层对齐
2.1 MVC 不是目录结构,而是职责隔离的契约:拒绝“伪 MVC”陷阱
很多所谓“MVC 博客”只是把文件按model/,view/,controller/分了三个文件夹,但ArticleController.php里仍直接echo "<div>...";,UserModel.php中混着header("Location: login.php")跳转逻辑,index.php同时加载所有类、硬编码 SQL 查询。这本质是“文件夹 MVC”,不是设计模式 MVC。真正的分层必须满足三条铁律:
- 控制器(C)只做三件事:解析 HTTP 请求参数(GET/POST)、调用模型方法获取或变更数据、决定渲染哪个视图并传入数据(绝不输出 HTML);
- 模型(M)只做两件事:定义数据表结构映射(如
Article类对应articles表)、封装 CRUD 操作(如save()方法内部只调用 PDO 预处理,不涉及任何 URL 或 session); - 视图(V)只做一件事:接收控制器传入的纯数组/对象,用 PHP 原生语法(
<?php foreach($posts as $p): ?>)生成 HTML,零业务逻辑。
提示:判断是否真 MVC,只需删掉
view/目录——如果controller/里的代码报错(如echo找不到),说明它没越界;如果model/里出现$_SESSION或header(),说明它已失职。
2.2 手写路由:用.htaccess+index.php实现无框架 URL 调度
现代 PHP 项目依赖mod_rewrite实现干净 URL(如/post/123而非/index.php?id=123)。在public/.htaccess中写入:
<IfModule mod_rewrite.c> RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^(.*)$ index.php?url=$1 [QSA,L] </IfModule>此配置将所有非真实文件/目录的请求,统一转发给index.php,并把路径作为url参数传递。index.php的核心调度逻辑如下:
<?php // public/index.php require_once '../app/bootstrap.php'; // 解析 URL:/post/123 → ['post', '123'] $url = $_GET['url'] ?? ''; $segments = array_filter(explode('/', trim($url, '/'))); // 默认控制器与动作 $controller = 'HomeController'; $action = 'index'; $params = []; if (!empty($segments)) { $controllerName = ucfirst($segments[0]) . 'Controller'; // post → PostController $controllerFile = '../app/controllers/' . $controllerName . '.php'; if (file_exists($controllerFile)) { require_once $controllerFile; $controller = $controllerName; if (isset($segments[1])) { $action = $segments[1]; // /post/show → show $params = array_slice($segments, 2); // /post/show/123/abc → [123, abc] } } } // 实例化控制器并调用动作 $controllerInstance = new $controller(); $controllerInstance->$action(...$params);这段代码不依赖任何外部库,却完成了框架级的路由解析:它把 URL 路径转换为控制器类名、动作方法名和参数数组,且支持/category/php、/user/profile/5等多段式路径。关键点在于array_slice($segments, 2)—— 它让第三个及以后的路径段自动成为动作方法的参数,无需手动$_GET解析。
2.3 请求生命周期闭环:从入口到响应的五步链路
一个典型请求(如访问/post/42)的完整链路如下:
- Apache 接收请求→ 触发
.htaccess重写规则; index.php解析路径→ 识别出控制器PostController、动作show、参数[42];- 加载
PostController.php→ 其中show($id)方法被调用; - 控制器调用模型→
$post = PostModel::find($id)返回关联数组; - 控制器分配数据并渲染视图→
require '../app/views/post/show.php',视图仅用<?php echo $post['title']; ?>输出。
这个链路中,PostModel::find()必须返回纯数据(如['id'=>42,'title'=>'MVC原理','content'=>'...']),绝不能包含echo或重定向。若某步失败(如$id不存在),控制器应调用http_response_code(404)并渲染404.php视图,而非die('not found')—— 这才是分层应有的错误处理粒度。
3. MySQL 数据库设计与 PHP 模型层实现:用 PDO 预处理杜绝 SQL 注入,用命名约定降低心智负担
3.1 博客核心表结构:兼顾范式与查询效率的 4 张表设计
基于标题中php mysql博客的需求,我们设计最小可用的四张表,避免过度设计(如不设标签中间表,用逗号分隔;不设复杂权限体系,用is_admin布尔字段):
| 表名 | 字段 | 类型 | 说明 |
|---|---|---|---|
users | id,username,password_hash,email,is_admin,created_at | INT, VARCHAR(50), VARCHAR(255), VARCHAR(100), TINYINT, DATETIME | 用户基础信息,密码必须 bcrypt 加密 |
categories | id,name,slug | INT, VARCHAR(50), VARCHAR(50) | 分类名称与 URL 友好别名(如php→php) |
posts | id,title,slug,content,user_id,category_id,status,created_at,updated_at | INT, VARCHAR(200), VARCHAR(200), TEXT, INT, INT, ENUM('draft','published'), DATETIME, DATETIME | 文章主表,status控制发布状态 |
comments | id,post_id,author_name,author_email,content,approved,created_at | INT, INT, VARCHAR(100), VARCHAR(100), TEXT, TINYINT, DATETIME | 评论表,approved=0表示待审核 |
注意:
slug字段用于生成 SEO 友好 URL(如/post/why-mvc-in-php),必须唯一且由标题自动生成(用mb_strtolower(str_replace([' ', ',', '。'], '-', $title))处理中文),避免直接用id做路径。
3.2 基础模型类Model.php:用 PDO 封装通用 CRUD,消除重复 SQL
所有模型继承自基类Model,它提供数据库连接、预处理查询、结果集映射等通用能力。app/models/Model.php内容如下:
<?php class Model { protected static $pdo; public function __construct() { if (self::$pdo === null) { $dsn = 'mysql:host=localhost;dbname=blog;charset=utf8mb4'; $options = [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]; self::$pdo = new PDO($dsn, 'root', 'password', $options); } } // 通用查询:SELECT * FROM table WHERE id = ? protected function find($table, $id) { $sql = "SELECT * FROM {$table} WHERE id = ?"; $stmt = self::$pdo->prepare($sql); $stmt->execute([$id]); return $stmt->fetch(); } // 通用列表:SELECT * FROM table ORDER BY created_at DESC LIMIT ? protected function all($table, $limit = 10) { $sql = "SELECT * FROM {$table} ORDER BY created_at DESC LIMIT ?"; $stmt = self::$pdo->prepare($sql); $stmt->execute([$limit]); return $stmt->fetchAll(); } // 通用保存:INSERT 或 UPDATE,根据是否存在 id 判断 protected function save($table, $data, $id = null) { if ($id) { // UPDATE $fields = implode(' = ?, ', array_keys($data)) . ' = ?'; $values = array_values($data); $sql = "UPDATE {$table} SET {$fields} WHERE id = ?"; $values[] = $id; } else { // INSERT $keys = implode(', ', array_keys($data)); $placeholders = str_repeat('?, ', count($data) - 1) . '?'; $sql = "INSERT INTO {$table} ({$keys}) VALUES ({$placeholders})"; $values = array_values($data); } $stmt = self::$pdo->prepare($sql); $stmt->execute($values); return $id ?: self::$pdo->lastInsertId(); } }此基类用PDO::ATTR_EMULATE_PREPARES => false强制使用真实预处理,彻底规避 SQL 注入;FETCH_ASSOC确保返回关联数组,与视图层无缝对接;save()方法自动区分插入/更新,减少子类重复逻辑。
3.3 具体模型实现:PostModel.php中的业务逻辑收敛
app/models/PostModel.php继承Model,只封装与文章强相关的逻辑:
<?php require_once 'Model.php'; class PostModel extends Model { // 根据 slug 查找文章(用于 /post/xxx 路由) public static function findBySlug($slug) { $sql = "SELECT p.*, u.username as author_name, c.name as category_name FROM posts p LEFT JOIN users u ON p.user_id = u.id LEFT JOIN categories c ON p.category_id = c.id WHERE p.slug = ? AND p.status = 'published'"; $stmt = self::$pdo->prepare($sql); $stmt->execute([$slug]); return $stmt->fetch(); } // 获取某分类下的文章列表 public static function getByCategory($categoryId, $limit = 10) { $sql = "SELECT * FROM posts WHERE category_id = ? AND status = 'published' ORDER BY created_at DESC LIMIT ?"; $stmt = self::$pdo->prepare($sql); $stmt->execute([$categoryId, $limit]); return $stmt->fetchAll(); } // 搜索文章标题或内容 public static function search($keyword, $limit = 10) { $sql = "SELECT * FROM posts WHERE status = 'published' AND (title LIKE ? OR content LIKE ?) ORDER BY created_at DESC LIMIT ?"; $stmt = self::$pdo->prepare($sql); $likeKeyword = "%{$keyword}%"; $stmt->execute([$likeKeyword, $likeKeyword, $limit]); return $stmt->fetchAll(); } }注意findBySlug()中的LEFT JOIN—— 它一次性查出作者名和分类名,避免视图层多次查询(N+1 问题)。所有方法均返回数组,不操作$_SESSION或输出 HTML,严格守界。
4. 视图层与控制器协同:用数据契约驱动渲染,用布局模板统一 UI 结构
4.1 布局模板layout.php:定义 HTML 骨架与动态区块
app/views/layout.php是所有页面的公共外壳,它通过<?php echo $content; ?>插入具体页面内容,并预留<?php if (isset($title)): ?>等动态区域:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title><?= htmlspecialchars($title ?? '我的博客') ?></title> <meta name="description" content="<?= htmlspecialchars($description ?? '') ?>"> <link rel="stylesheet" href="/css/style.css"> </head> <body> <header> <h1><a href="/">我的博客</a></h1> <nav> <a href="/">首页</a> | <a href="/category/php">PHP</a> | <a href="/about">关于</a> </nav> </header> <main> <?= $content ?> </main> <footer> © <?= date('Y') ?> 我的博客. 用 PHP + MySQL 手写 MVC. </footer> </body> </html>关键点:htmlspecialchars()防止 XSS;$content是控制器传入的字符串(通常是require的子视图输出);导航链接用绝对路径/category/php,确保跨页面一致。
4.2 控制器如何安全传递数据:用数组解构而非全局变量
app/controllers/PostController.php的show()方法示例:
<?php require_once '../models/PostModel.php'; class PostController extends Controller { public function show($slug) { $post = PostModel::findBySlug($slug); if (!$post) { http_response_code(404); $this->view('errors/404', ['title' => '文章未找到']); return; } // 准备视图数据:只传必要字段,不传敏感信息(如 password_hash) $data = [ 'title' => htmlspecialchars($post['title']), 'content' => nl2br(htmlspecialchars($post['content'])), // 保留换行,转义 HTML 'author' => htmlspecialchars($post['author_name']), 'category' => htmlspecialchars($post['category_name']), 'created_at' => date('Y-m-d H:i', strtotime($post['created_at'])), ]; $this->view('post/show', $data); } }$this->view()是控制器基类的方法,它负责引入布局并注入$content:
// app/controllers/Controller.php class Controller { protected function view($viewPath, $data = []) { // 提取数据到局部变量,供视图使用 extract($data); // 渲染子视图到缓冲区 ob_start(); require '../app/views/' . $viewPath . '.php'; $content = ob_get_clean(); // 渲染布局,传入 $content 和其他可能的变量 require '../app/views/layout.php'; } }extract($data)将数组键转为变量名($title,$content),使视图代码简洁;ob_start()/ob_get_clean()确保子视图输出被捕获,而非直接刷屏。
4.3 子视图post/show.php:纯粹的数据消费,零逻辑判断
app/views/post/show.php内容极简:
<article> <h1><?= $title ?></h1> <p class="meta">作者:<?= $author ?> | 分类:<?= $category ?> | 发布于:<?= $created_at ?></p> <div class="content"> <?= $content ?> </div> <div class="comments-section"> <h3>评论(<?= count($comments ?? []) ?>)</h3> <!-- 评论表单与列表在此处,调用 CommentsController --> </div> </article>它不做任何数据库查询、不调用模型、不处理表单提交——这些都由CommentsController在show()方法中提前准备好$comments数组并传入。视图只负责“把数据变成 HTML”。
5. 关键参数配置与常见排错:从 MySQL 连接失败到 PDO 预处理失效的定位路径
5.1config.php中的 5 个必调参数:环境隔离与安全加固
app/config.php是整个系统的配置中枢,必须包含以下参数,且生产环境需从.env文件读取(此处简化为常量):
| 参数 | 示例值 | 说明 | 排错指向 |
|---|---|---|---|
DB_HOST | 'localhost' | MySQL 服务地址,Docker 环境需改为'db' | PDOException: SQLSTATE[HY000] [2002] Connection refused→ 检查 MySQL 是否运行、端口是否开放 |
DB_NAME | 'blog' | 数据库名,需提前用CREATE DATABASE blog CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;创建 | SQLSTATE[HY000] [1049] Unknown database 'blog'→ 登录 MySQL 执行建库语句 |
DB_USER | 'blog_user' | 专用数据库用户,禁止用 root | SQLSTATE[HY000] [1045] Access denied→ 检查用户权限GRANT SELECT,INSERT,UPDATE,DELETE ON blog.* TO 'blog_user'@'localhost'; |
DB_PASS | 'strong_password' | 密码,必须强随机 | SQLSTATE[HY000] [1045] Access denied→ 确认密码无空格、特殊字符未被 URL 编码 |
APP_ENV | 'development' | 开发/生产环境,影响错误显示(开发显示详细错误,生产只显示 500) | 白屏无报错 → 检查display_errors = Off是否开启,或APP_ENV是否为production |
提示:
utf8mb4是必须的,否则 emoji 和部分中文会乱码。建库时务必指定CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,并在 PDO DSN 中显式声明charset=utf8mb4。
5.2 三个高频报错的精准定位指令
当系统无法运行时,按顺序执行以下命令,比盲目 Google 更高效:
检查 MySQL 连接基础:
mysql -h localhost -u blog_user -p -e "USE blog; SHOW TABLES;"若提示
Access denied,说明用户权限或密码错误;若提示Unknown database,说明库未创建。验证 PDO 预处理是否启用:
<?php $pdo = new PDO('mysql:host=localhost;dbname=blog', 'blog_user', 'pass'); var_dump($pdo->getAttribute(PDO::ATTR_EMULATE_PREPARES)); // 应为 int(0) ?>若返回
int(1),说明预处理被模拟,SQL 注入风险存在,需在 DSN 中加;PDO::ATTR_EMULATE_PREPARES => false。捕获视图层 XSS 漏洞:
在layout.php的<title>标签内临时加入:<title><?= var_dump($title); exit; ?></title>访问页面,若输出
string(5) "test"说明$title已正确传入;若输出NULL,说明控制器未传参或extract()失败。
5.3 MySQL 索引优化:针对博客查询场景的 3 个必备索引
博客系统中,以下查询频次最高,必须建立对应索引:
| 查询场景 | 原始 SQL 片段 | 必建索引 | 命令 |
|---|---|---|---|
| 按 slug 查文章 | WHERE p.slug = ? | posts(slug) | ALTER TABLE posts ADD INDEX idx_slug (slug); |
| 按分类查文章 | WHERE p.category_id = ? | posts(category_id) | ALTER TABLE posts ADD INDEX idx_category_id (category_id); |
| 按状态+时间排序 | WHERE p.status = 'published' ORDER BY created_at DESC | posts(status, created_at) | ALTER TABLE posts ADD INDEX idx_status_created (status, created_at); |
注意:复合索引
(status, created_at)比单列索引created_at更高效,因为WHERE status = 'published'是高选择性条件,MySQL 能先用status过滤再按created_at排序,避免filesort。
6. 用curl和mysql命令行快速验证系统健康度:5 条命令覆盖核心链路
6.1 验证 Web 层:用 curl 模拟真实请求,绕过浏览器缓存
# 1. 检查首页是否返回 200 且包含博客标题 curl -s -o /dev/null -w "%{http_code}" http://localhost/ # 2. 检查文章详情页(假设存在 slug 为 'mvc' 的文章) curl -s -I http://localhost/post/mvc | grep "HTTP/1.1 200" # 3. 检查 404 页面是否正确返回状态码 curl -s -o /dev/null -w "%{http_code}" http://localhost/post/nonexistent-slug # 4. 检查 POST 提交(如评论)是否重定向成功 curl -s -o /dev/null -w "%{redirect_url}" -X POST -d "name=test&email=test@example.com&content=hello" http://localhost/comment/add/42 # 5. 检查静态资源(CSS)是否可访问 curl -s -o /dev/null -w "%{http_code}" http://localhost/css/style.css这些命令返回200、302或404状态码,比打开浏览器更快定位是 Web 服务器、PHP 还是应用逻辑的问题。
6.2 验证数据库层:用 mysql 命令行直连,确认数据可读写
# 进入 MySQL 命令行 mysql -u blog_user -p blog # 1. 查看文章表数据量(应 > 0) SELECT COUNT(*) FROM posts WHERE status = 'published'; # 2. 检查最新一篇文章的 slug 和标题 SELECT slug, title FROM posts WHERE status = 'published' ORDER BY created_at DESC LIMIT 1; # 3. 模拟控制器中的 JOIN 查询,验证关联是否正常 SELECT p.title, u.username, c.name FROM posts p JOIN users u ON p.user_id = u.id JOIN categories c ON p.category_id = c.id WHERE p.slug = 'mvc' AND p.status = 'published'; # 4. 检查索引是否生效(key_len > 0 表示用了索引) EXPLAIN SELECT * FROM posts WHERE slug = 'mvc'; # 5. 插入测试数据(验证写权限) INSERT INTO posts (title, slug, content, user_id, category_id, status) VALUES ('测试文章', 'test-post', '这是测试内容', 1, 1, 'published');若EXPLAIN中key_len为NULL,说明索引未被使用,需检查字段类型是否匹配(如slug是VARCHAR(200),查询条件也必须是字符串)。
6.3 终极验证:用一条命令导出当前数据库结构与数据快照
mysqldump -u blog_user -p --no-create-info --where="status='published'" blog posts categories > blog_published_data.sql此命令导出所有已发布文章和分类数据(不含建表语句),可用于:
- 向同事分享最小可运行数据集;
- 在新环境快速初始化测试数据;
- 对比不同环境的数据一致性。
导出文件可直接用mysql -u blog_user -p blog < blog_published_data.sql导入,是系统交付前的最后保险。
本文还有配套的精品资源,点击获取