简介:面向PHP初学者的简易博客系统项目源码,基于Brophp开发,首页通过动态参数区分不同用户,页面采用布局模板复用公共结构,覆盖文章展示、用户注册、登录会话、后台管理等典型模块。整包共636个文件,以240个PHP脚本为核心,并配有Web页面、样式表、JavaScript脚本以及多种常见图片素材,另含数据库文件和模板文件,压缩包仅2.75MB,适合本地搭建学习。已有102人学习,代码注释详细、目录结构完整,可帮助初学者理解MVC分层、PHP操作MySQL进行增删改查、参数路由和用户认证流程。通过阅读和运行这套代码,能直观看到博客系统从数据库设计到前端渲染的完整链条,是PHP入门阶段兼顾理论讲解与动手实践的参考范例,对刚接触Web开发的学生或缺少项目经验的自学者尤其友好,也可作为课程设计或期末项目的参考资料。
1. 从 index.php?id= 入口拆解 BroPHP 博客的请求链路
解压 Blog.rar,第一眼看到的是一堆 1324035677.aaa、1324041983.adadadadada 这样的乱码文件,它们是打包时混进来的上传残留,直接删掉即可。真正的主体是 Blog 目录里的站点:一个基于 BroPHP 轻量级框架写的 PHP 博客,代码量不大,但 MVC 分层、layout 布局、URL 参数路由、用户注册登录这几块都有完整落地。最有代表性的入口是 index.php?id=xxx——一个 id 参数既定位文章详情,也定位用户主页。框架怎么解析它、控制器怎么接收、模板怎么安全输出,这条链路理清楚,一个 PHP 项目从请求到响应的全流程也就看明白了。适合刚学完 PHP 语法、想看真实项目怎么组织代码的初学者,也适合面试前用十分钟快速过一遍 MVC 常见写法。
2. BroPHP 的 MVC 骨架与 layout 布局机制
2.1 入口文件与目录划分
博客站点不是把一堆页面堆在一起,而是按职责拆成三层。解压后先看目录结构,这是读懂代码的第一张地图。教学型 PHP 框架的目录通常是这样的:
| 路径 | 职责 |
|---|---|
| index.php | 前端控制器,所有请求的唯一入口 |
| App/Controller/ | 控制器层,接收参数、调用模型、挑选视图 |
| App/Model/ | 数据层,封装 SQL 和业务规则 |
| App/View/ | 模板文件,只管输出 HTML |
| config.php | 数据库连接、调试开关等全局配置 |
| BroPHP/ | 框架核心,路由、模板引擎、数据库封装 |
把入口收敛到 index.php 一个文件,是为了让所有请求都经过同一条管线:先做参数过滤,再决定调用哪个控制器,最后统一渲染。如果不这样做,每个页面单独写一个 php 文件,公共逻辑(比如登录检查)就得复制 N 份,改一处漏一处。这也是 MVC 最直接的价值——文件归类清晰,改业务时知道去哪找代码。
入口文件的写法一般是这样的:
<?php // index.php 所有请求的唯一入口 define('APP_PATH', __DIR__ . '/App/'); // 应用目录 define('BROPHP_PATH', __DIR__ . '/BroPHP/'); // 框架核心目录 require BROPHP_PATH . 'Brophp.php'; // 加载框架启动文件 Brophp::run(); // 启动,内部完成路由分发两个常量决定了框架去哪找控制器和模型:APP_PATH 指向应用代码,BROPHP_PATH 指向框架本身。run() 做的事用一句话概括就是"看参数、找类、调方法",具体解析逻辑在 2.3 节展开。这两个常量也可以用于后续扩展,比如把 APP_PATH 改成公共应用目录,多个前台后台共用一套框架核心。
2.2 layout 布局的渲染顺序
博客每个页面都有相同的头部导航和底部版权,如果每个模板都复制一份,改一次导航要动十几个文件。BroPHP 这类教学框架普遍采用 layout 布局文件解决:写一个包含 header、footer 的主模板,中间留一个内容占位符,控制器只渲染各自的内容片段,再由框架把片段塞回布局里。
主模板大致长这样:
<!-- App/View/layout.html 布局主模板 --> <!DOCTYPE html> <html> <head> <title>{$title}</title> </head> <body> <div id="header"> <a href="index.php?c=article&a=index">首页</a> {if isset($_SESSION['uid'])} <a href="index.php?c=user&a=profile&id={$_SESSION['uid']}">{$_SESSION['username']}</a> {else} <a href="index.php?c=user&a=login">登录</a> {/if} </div> <div id="main">{__CONTENT__}</div> <div id="footer">© 2025 My Blog</div> </body> </html>{CONTENT} 是内容占位符,框架渲染时会把当前页面的内容模块替换进来。模板里<div id="header">的 id 是给 CSS id 选择器用的,和 URL 里的 ?id= 参数是两码事,别混。控制器指定布局的方式:
// ArticleController 里指定布局并渲染内容 public function index() { $this->assign('title', '文章列表'); // 把变量交给模板 $this->layout = 'layout'; // 使用 layout.html 作为主模板 $this->display('article/list'); // 只渲染内容片段 }渲染顺序是:先执行 display() 渲染 article/list.html 生成 HTML 片段,再把整个片段替换进 layout.html 的 {CONTENT} 位置,最后输出完整页面。每页只需要维护自己这一小块内容,公共部分全部收在 layout 里,这就是 layout 布局相比"每个页面 include 公共头部"更省心的原因——include 方式虽然也能复用,但变量作用域和重复 include 的边界很难控制。
2.3 从 id 参数解析到控制器方法
有了目录和布局,剩下最关键的是路由解析。这个项目的入口参数是原生 GET 风格,也就是 index.php?c=user&a=profile&id=3 这种形式,而不是 rewrite 之后的 /user/profile/3。两者的差别在于:原生 GET 不需要配置伪静态,对初学者更容易看到参数从哪来、到哪去,排查问题也更直接。
框架内部的路由解析,简化后就是这个逻辑:
// BroPHP 路由分发(简化版) $c = isset($_GET['c']) ? $_GET['c'] : 'user'; // 控制器名,默认 user $a = isset($_GET['a']) ? $_GET['a'] : 'index'; // 方法名,默认 index $controllerFile = APP_PATH . 'Controller/' . ucfirst($c) . 'Controller.class.php'; if (!file_exists($controllerFile)) { exit('控制器不存在'); } require $controllerFile; $controllerName = ucfirst($c) . 'Controller'; $obj = new $controllerName(); // 实例化控制器 $obj->$a(); // 调用对应方法c、a、id 三个参数的分工是固定的,需要记住:
| 参数 | 含义 | 示例 |
|---|---|---|
| c | controller,控制器名 | c=user 表示 UserController |
| a | action,控制器里的方法名 | a=profile 表示调用 profile() |
| id | 主键或资源编号,GET 字符串 | id=3 表示取数据库里主键为 3 的记录 |
控制器方法里一般不会直接操作$_GET['id'],而是通过框架的取值方法统一过滤,比如$this->_get('id')。这样做的目的是在同一处完成默认值、类型转换和安全过滤,避免每个方法各自写一遍 isset 判断,漏一个就多一个注入点。到这里,"index.php?id=用户"这个入口的链路就算打通了:参数进路由,路由找控制器,控制器取 id、调模型、渲染 layout。
3. 用户模块落地:注册登录与 id 参数的安全使用
用户模块是这份代码里最值得细看的部分,因为"id 参数 + 用户"组合背后是三个问题:数据表怎么存用户、会话怎么保持登录、用户主页的 id 参数怎么安全使用。
3.1 用户表的设计
先看建表语句。教学项目的表不会太复杂,但字段类型的选择本身就有讲究:
CREATE TABLE user ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT '用户主键', username VARCHAR(30) NOT NULL UNIQUE COMMENT '登录名,唯一', password CHAR(60) NOT NULL COMMENT 'password_hash 生成的哈希,固定 60 位', nickname VARCHAR(30) NOT NULL DEFAULT '' COMMENT '显示昵称', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';password 用 CHAR(60) 而不是 VARCHAR(32),是因为 password_hash() 默认生成的 bcrypt 哈希恰好是 60 个字符。用定长 CHAR 一方面长度确定、索引更友好,另一方面也暗示一个事实:密码绝对不能存明文,也不能存 md5(密码) 这种可查彩虹表的旧式摘要,校验时用配套的 password_verify() 完成,这是 PHP 5.5 之后的标准做法。
nickname 单独分出来,是因为登录名 username 往往不允许修改,而显示名可以随便改。这种"登录标识"和"展示字段"分离的设计在真实项目里很常见:日志、URL、权限判断全部走 username 或 uid,页面展示走 nickname,两边互不干扰。
3.2 注册与登录的控制器逻辑
注册和登录走的是同一个模式:先判断是不是 POST 提交,是则取表单、做校验、操作数据,否则直接渲染表单页。注册控制器大致如下:
// App/Controller/UserController.class.php class UserController extends Controller { public function register() { if ($_SERVER['REQUEST_METHOD'] === 'POST') { $username = trim($_POST['username']); $password = $_POST['password']; $confirm = $_POST['confirm']; if ($username === '' || $password === '') { $this->error('用户名和密码不能为空'); } if ($password !== $confirm) { $this->error('两次密码不一致'); } if ($this->model('user')->isExists($username)) { $this->error('用户名已被注册'); } $this->model('user')->add([ 'username' => $username, 'password' => password_hash($password, PASSWORD_DEFAULT), 'nickname' => $username, ]); $this->success('注册成功', 'index.php?c=user&a=login'); } $this->layout = 'layout'; $this->display('user/register'); } }这段代码的校验顺序有讲究:先查空,再比两次密码,最后查重。查重放在最后,是因为查库开销最大,能前置的轻量校验先做完,无效请求就不会打到数据库。password_hash 的 PASSWORD_DEFAULT 参数表示使用当前 PHP 推荐的算法,以后 PHP 升级算法时旧哈希依然能被 password_verify 正确识别,这也是它在兼容性上优于 md5 自拼盐方案的原因。
登录方法则是把校验成功的用户 id 写进 session:
public function login() { if ($_SERVER['REQUEST_METHOD'] === 'POST') { $username = trim($_POST['username']); $password = $_POST['password']; $user = $this->model('user')->findByName($username); if ($user && password_verify($password, $user['password'])) { $_SESSION['uid'] = $user['id']; $_SESSION['username'] = $user['username']; $this->success('登录成功', 'index.php?c=article&a=index'); } $this->error('用户名或密码错误'); } $this->layout = 'layout'; $this->display('user/login'); }注意登录成功跳转的是文章列表而不是用户中心,这是博客这类内容站的习惯:登录后第一眼看到的是内容,而不是自己的资料页。$_SESSION['uid'] 只存数据库主键,不存整个用户数组,需要昵称等字段时再按需查一次,避免 session 里堆着可能过期的冗余数据。另外,登录失败统一报"用户名或密码错误",不要区分是用户名不存在还是密码错,避免攻击者通过报错枚举合法用户名。
3.3 用户主页与 id 参数的安全使用
用户主页的入口就是项目名里反复出现的 index.php?id=用户。这里最容易出问题的是:id 参数是外部输入,不能直接拿去拼 SQL,控制器的写法通常是先转整数再查询:
public function profile() { $id = intval($this->_get('id')); // 字符串 "3abc" 会被转成 3 $user = $this->model('user')->find($id); if (!$user) { $this->error('用户不存在'); } $articles = $this->model('article')->listByUid($id); // 该作者的文章 $this->assign('user', $user); $this->assign('articles', $articles); $this->assign('title', $user['nickname'] . '的个人主页'); $this->layout = 'layout'; $this->display('user/profile'); }intval() 在这里是双保险:即便有人传 id=3%20or%201=1,intval 也会把它截断成 3,SQL 注入的拼接入口直接失效。真正查库时,模型内部还要再用预处理参数,两层叠起来才算安全。
提示:任何把外部参数与 SQL 字符串拼接的代码,无论外面套了多少层函数,都不能算安全。intval 只是兜底,真正的防线是预处理。
另外,用户主页展示的是公开信息,所以这里不需要登录也能访问;但做"编辑个人资料"这类接口时,就必须先校验 session 里的 uid 是否等于传入的 id,否则任何人都能改别人的资料。常见误用对比,排查问题时直接对着看:
| 写法 | 问题 |
|---|---|
| where id = $_GET['id'] | 字符串拼接,存在注入 |
| where id = (int)$_GET['id'] | 强制转换后可防注入,但语义不清晰 |
| where id = :id 预处理 | 标准做法,类型与转义交给驱动 |
4. 文章 CRUD 与模板输出细节
用户表看完,接着是博客的核心业务:文章的增删改查。这一章重点看列表、详情、发布三块,以及模板引擎输出时的转义行为。
4.1 文章表与分页查询
文章表比用户表多了一个归属字段 uid,表示作者:
CREATE TABLE article ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, title VARCHAR(100) NOT NULL COMMENT '标题', content TEXT NOT NULL COMMENT '正文', uid INT UNSIGNED NOT NULL COMMENT '作者 user.id', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_uid (uid) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里没有写 FOREIGN KEY 外键约束,教学项目常见这种做法:外键关系由应用层保证,插入文章前先确认 uid 在 user 表存在。理由是不想在删用户时被级联约束绊住,牺牲数据库层面的严谨换取逻辑简单。生产环境是否加外键是另一回事,但看这份代码至少要能说出"uid 是逻辑外键"这句话。
列表页的分页查询是面试常问的点:
public function index() { $page = max(1, intval($this->_get('page', 1))); // 当前页码,最小为 1 $size = 10; // 每页条数 $offset = ($page - 1) * $size; // 偏移量 $list = $this->model('article')->pageList($offset, $size); $total = $this->model('article')->count(); $this->assign('list', $list); $this->assign('pageHtml', $this->_page($total, $size, $page)); $this->assign('title', '文章列表'); $this->layout = 'layout'; $this->display('article/list'); }分页的核心是 LIMIT offset, size 这条 SQL:
SELECT a.*, u.username FROM article a LEFT JOIN user u ON a.uid = u.id ORDER BY a.id DESC LIMIT 10 OFFSET 0;LEFT JOIN 把作者名一次查出来,避免在模板里循环查库。ORDER BY id DESC 让新文章排前面,对自增主键来说 id 顺序就是时间顺序,不需要额外的发布时间索引。page、size、offset 三个值要分清:page 是给人看的第几页,size 是每页条数,offset 是给数据库的跳过条数,offset 恒等于 (page-1)*size。
4.2 详情页的 id 路由与模板转义
文章详情页就是 index.php?c=article&a=show&id=5,控制器取 id 的方式和用户主页一致,区别在于输出时要多考虑 XSS 问题。列表页的 URL 参数有三个,整理如下:
| 参数 | 作用 | 示例 |
|---|---|---|
| c | 控制器 | index.php?c=article |
| a | 方法 | &a=index 或 &a=show |
| page / id | 页码或文章主键 | &page=2 / &id=5 |
博客文章是富文本,content 字段可能含有 HTML 标签,模板输出时不能一刀切地转义,否则标题里的引号会变成实体。看模板循环的输出写法:
<!-- App/View/article/list.html --> {foreach $list as $row} <div class="post"> <h2> <a href="index.php?c=article&a=show&id={$row['id']}">{$row['title']}</a> </h2> <p>{$row['content']|strip_tags|mb_substr:0:80}...</p> <span>作者: <a href="index.php?c=user&a=profile&id={$row['uid']}">{$row['username']}</a> | {$row['created_at']} </span> </div> {/foreach} <div class="pagination">{$pageHtml}</div>{$row['content']|strip_tags|mb_substr:0:80} 是模板引擎的管道过滤器写法,和 Linux 管道的思路一样:先用 strip_tags 剥掉所有 HTML 标签,再用 mb_substr 按字符截取前 80 个,两个过滤器从左到右依次执行。列表页只显示摘要,进入详情页才输出完整 content,这个设计让列表页加载快很多,也避免把大段富文本塞进列表 DOM。
需要说明的是,title 这里的 { } 输出默认是转义过的,所以"和<都会被安全编码;而 content 在详情页一般按"信任站内作者"处理直接输出。如果系统允许匿名投稿,content 就必须在入库前用 htmlpurifier 之类的库过滤,这是教学代码通常不会细讲、但上线前必须补的一课。
4.3 发表文章的权限与表单数据
发表文章的控制器比前面多一个环节:登录检查。URL 上暴露功能入口很容易,但执行权限必须拦在服务端:
public function add() { if (!isset($_SESSION['uid'])) { $this->error('请先登录', 'index.php?c=user&a=login'); } if ($_SERVER['REQUEST_METHOD'] === 'POST') { $title = trim($_POST['title']); $content = trim($_POST['content']); if ($title === '' || $content === '') { $this->error('标题和内容不能为空'); } $id = $this->model('article')->add([ 'title' => $title, 'content' => $content, 'uid' => $_SESSION['uid'], // 作者来自会话,不信任表单 ]); $this->success('发布成功', 'index.php?c=article&a=show&id=' . $id); } $this->layout = 'layout'; $this->display('article/add'); }uid 必须从 $_SESSION 取而不能从表单取,这是这条代码最重要的细节。如果信任 $_POST['uid'],攻击者完全可以伪造别人的 uid 发文章。发布成功后用 insert 返回的自增 id 拼接跳转地址,用户立刻能看到刚写的文章,这个交互顺序比跳回列表强。delete 和 update 也遵循同样套路,额外多一步:比较 session uid 和文章记录的 uid,不一致直接报错。这套"取 id、查归属、比权限、再操作"的流程,是所有带用户体系的 CRUD 应用都要遵守的模板。
5. 实战排错:layout 失效、id 传参丢失与注入自查
最后给三个高频排错点,都是实际跑这个项目时容易踩的坑。
5.1 layout 不生效的三个原因
现象是页面只渲染了内容片段,头部导航和底部版权全部消失,露出一个光秃秃的 div。按顺序排查:
| 现象 | 原因 | 处理 |
|---|---|---|
| 内容区有完整 HTML 但无头尾 | 控制器没设 $this->layout,或 layout 文件名拼错 | 检查控制器 layout 属性,确认 View 目录下文件名是 layout.html |
| 页面出现字面量 {CONTENT} | 占位符名称和框架常量不一致 | 搜框架里 define 的占位符常量,模板改成一致 |
| 头尾是旧版本 | 模板编译缓存未刷新 | 删除 Runtime 目录下的编译文件,重新访问 |
第三种最常见。教学框架普遍做模板预编译:模板首次被访问时编译成 PHP 文件存进 Runtime,后续直接执行编译产物。改完模板不生效,九成是编译缓存没清,开发期直接关掉模板缓存开关最省事。
5.2 id 传参丢失的定位
id 参数丢失时,先确认请求真的带上了参数:
# 查看 Web 服务器访问日志,确认原始 URL tail -f /var/log/nginx/access.log | grep "index.php"再在控制器入口把原始参数打出来:
$raw = $_GET['id'] ?? '未传参'; var_dump($raw, intval($raw)); // 看原始值和转换后的值 exit;如果原始值是空而 URL 明明有 id,多半是被 rewrite 规则拦截了;如果原始值是 "3abc" 这类脏数据,那就是前端拼接 URL 时混入了多余字符,常见于把数组直接拼进字符串。id 参数在 PHP 侧最终一定要过 intval 或 (int) 转型,这是最后一道防线。
5.3 SQL 注入自查清单
最后给一个通用的自查动作,不光是这个项目,任何 PHP 项目都能用:
// 危险:字符串直接拼接,$_GET['id'] 可注入 $sql = "SELECT * FROM article WHERE id = " . $_GET['id']; $res = $pdo->query($sql); // 安全:预处理占位符,参数与 SQL 分离 $stmt = $pdo->prepare("SELECT * FROM article WHERE id = ?"); $stmt->execute([(int) $_GET['id']]);两段代码的差别不在写法,而在"数据是否经过 SQL 解析":拼接写法里 $_GET['id'] 的内容会被当成 SQL 的一部分解析,预处理写法把参数当作纯数据传给驱动,无论传什么都不会改变语句结构。自查时全文搜索两个高危特征即可:字符串里包含 SELECT/UPDATE/DELETE 且紧跟. $_GET或. $_POST,以及模型方法内部用双引号直接包变量进 SQL。搜到一处改一处,全部改成预处理后,再用/index.php?c=article&a=show&id=1%20or%201=1这类探针请求多打几个,同时观察访问日志,直到不再出现包含拼接 SQL 的报错条目为止。
本文还有配套的精品资源,点击获取