PHP: 综合项目:博客系统(上)
36 课学到现在,你已经掌握了 PHP、MySQL、Session、OOP、文件操作所有基础技能。接下来的两课,把零散的知识点串成一个完整应用——一个真正的博客系统。
1. 项目架构
TEXT
📖 仅展示
myblog/ ← 项目根目录
├── app/
│ ├── Controllers/
│ │ └── PostController.php ← 文章控制器
│ ├── Models/
│ │ └── Post.php ← 文章模型
│ └── Core/
│ ├── Router.php ← 路由
│ └── Database.php ← 数据库连接
├── views/
│ ├── layout.php ← 页面框架
│ ├── home.php ← 首页
│ └── posts/
│ ├── index.php ← 文章列表
│ └── show.php ← 文章详情
├── public/
│ └── index.php ← 入口文件(所有请求进这里)
├── config.php ← 配置文件
├── composer.json
└── uploads/ ← 上传目录
2. 核心文件
(1) config.php
PHP
<?php
return [
'db' => [
'host' => 'localhost',
'dbname' => 'myblog',
'username' => 'root',
'password' => '',
'charset' => 'utf8mb4',
],
'app' => [
'name' => 'MyBlog',
'url' => 'http://localhost/myblog',
],
];
(2) 数据库连接(Database.php)
▶ 示例:数据库单例连接
PHP
<?php
namespace App\Core;
use PDO;
class Database
{
private static ?PDO $instance = null;
public static function getInstance(): PDO
{
if (self::$instance === null) {
$config = require __DIR__ . '/../../config.php';
$db = $config['db'];
$dsn = "mysql:host={$db['host']};dbname={$db['dbname']};charset={$db['charset']}";
self::$instance = new PDO($dsn, $db['username'], $db['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
return self::$instance;
}
}
(3) 路由(Router.php)
▶ 示例:动态路由匹配
PHP
<?php
namespace App\Core;
class Router
{
private array $routes = [];
public function add(string $method, string $path, callable $handler): void
{
$this->routes[] = compact('method', 'path', 'handler');
}
public function dispatch(string $method, string $uri): void
{
$path = parse_url($uri, PHP_URL_PATH);
foreach ($this->routes as $route) {
// 支持动态参数 /posts/123
$pattern = preg_replace('/\{(\w+)\}/', '(?P<$1>\d+)', $route['path']);
$pattern = '#^' . $pattern . '$#';
if ($route['method'] === $method && preg_match($pattern, $path, $matches)) {
$params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
echo $route['handler']($params);
return;
}
}
http_response_code(404);
echo '<h2>404 — 页面未找到</h2>';
}
}
(4) 入口文件(public/index.php)
PHP
<?php
session_start();
require_once __DIR__ . '/../app/Core/Database.php';
require_once __DIR__ . '/../app/Core/Router.php';
require_once __DIR__ . '/../app/Models/Post.php';
require_once __DIR__ . '/../app/Controllers/PostController.php';
use App\Core\Router;
use App\Controllers\PostController;
$router = new Router();
$ctrl = new PostController();
$router->add('GET', '/', [$ctrl, 'home']);
$router->add('GET', '/posts', [$ctrl, 'index']);
$router->add('GET', '/posts/{id}', [$ctrl, 'show']);
$router->add('GET', '/posts/create', [$ctrl, 'createForm']);
$router->add('POST', '/posts/create', [$ctrl, 'create']);
$router->add('GET', '/register', [$ctrl, 'registerForm']);
$router->add('POST', '/register', [$ctrl, 'register']);
$router->add('GET', '/login', [$ctrl, 'loginForm']);
$router->add('POST', '/login', [$ctrl, 'login']);
$router->add('GET', '/logout', [$ctrl, 'logout']);
$router->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
3. 文章模型(Post.php)
PHP
<?php
namespace App\Models;
use App\Core\Database;
use PDO;
class Post
{
private PDO $db;
public function __construct()
{
$this->db = Database::getInstance();
}
/** 获取所有已发布文章 */
public function getAll(): array
{
$stmt = $this->db->query(
"SELECT p.*, u.username
FROM posts p
JOIN users u ON p.user_id = u.id
WHERE p.status = 'published'
ORDER BY p.created_at DESC"
);
return $stmt->fetchAll();
}
/** 根据 ID 获取单篇文章 */
public function findById(int $id): ?array
{
$stmt = $this->db->prepare(
"SELECT p.*, u.username
FROM posts p
JOIN users u ON p.user_id = u.id
WHERE p.id = :id AND p.status = 'published'"
);
$stmt->execute(['id' => $id]);
$post = $stmt->fetch();
return $post ?: null;
}
/** 创建新文章 */
public function create(int $userId, string $title, string $content): int
{
$stmt = $this->db->prepare(
"INSERT INTO posts (user_id, title, content)
VALUES (:user_id, :title, :content)"
);
$stmt->execute([
'user_id' => $userId,
'title' => $title,
'content' => $content,
]);
return (int)$this->db->lastInsertId();
}
}
4. 控制器(PostController.php)
PHP
<?php
namespace App\Controllers;
use App\Models\Post;
use PDO;
class PostController
{
private Post $post;
private PDO $db;
public function __construct()
{
$this->post = new Post();
$this->db = \App\Core\Database::getInstance();
}
/** 首页 */
public function home(): string
{
$posts = $this->post->getAll();
ob_start();
require __DIR__ . '/../../views/home.php';
return ob_get_clean();
}
/** 文章列表 */
public function index(): string
{
$posts = $this->post->getAll();
ob_start();
require __DIR__ . '/../../views/posts/index.php';
return ob_get_clean();
}
/** 文章详情 */
public function show(array $params): string
{
$post = $this->post->findById((int)$params['id']);
if (!$post) {
http_response_code(404);
return '<h2>文章不存在</h2>';
}
ob_start();
require __DIR__ . '/../../views/posts/show.php';
return ob_get_clean();
}
/** 创建文章表单 */
public function createForm(): string
{
$this->requireLogin();
ob_start();
require __DIR__ . '/../../views/posts/create.php';
return ob_get_clean();
}
/** 处理创建文章 */
public function create(): string
{
$this->requireLogin();
$title = trim($_POST['title'] ?? '');
$content = trim($_POST['content'] ?? '');
if ($title === '' || $content === '') {
return '<p style="color:red">标题和内容不能为空</p><a href="/posts/create">返回</a>';
}
$id = $this->post->create(
$_SESSION['user']['id'],
$title,
$content
);
header("Location: /posts/{$id}");
exit;
}
/** 注册表单 */
public function registerForm(): string
{
ob_start();
require __DIR__ . '/../../views/auth/register.php';
return ob_get_clean();
}
/** 处理注册 */
public function register(): string
{
$username = trim($_POST['username'] ?? '');
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';
if ($username === '' || $email === '' || strlen($password) < 6) {
return '<p style="color:red">请正确填写所有字段(密码至少6位)</p><a href="/register">返回</a>';
}
// 检查用户是否已存在
$stmt = $this->db->prepare("SELECT COUNT(*) FROM users WHERE username = :u OR email = :e");
$stmt->execute(['u' => $username, 'e' => $email]);
if ($stmt->fetchColumn() > 0) {
return '<p style="color:red">用户名或邮箱已被注册</p><a href="/register">返回</a>';
}
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $this->db->prepare("INSERT INTO users (username, email, password) VALUES (:u, :e, :p)");
$stmt->execute(['u' => $username, 'e' => $email, 'p' => $hash]);
$_SESSION['user'] = ['id' => (int)$this->db->lastInsertId(), 'username' => $username];
header("Location: /");
exit;
}
/** 登录表单 */
public function loginForm(): string
{
ob_start();
require __DIR__ . '/../../views/auth/login.php';
return ob_get_clean();
}
/** 处理登录 */
public function login(): string
{
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
$stmt = $this->db->prepare("SELECT * FROM users WHERE username = :u");
$stmt->execute(['u' => $username]);
$user = $stmt->fetch();
if (!$user || !password_verify($password, $user['password'])) {
return '<p style="color:red">用户名或密码错误</p><a href="/login">返回</a>';
}
$_SESSION['user'] = ['id' => (int)$user['id'], 'username' => $user['username']];
header("Location: /");
exit;
}
/** 退出登录 */
public function logout(): void
{
session_unset();
session_destroy();
header("Location: /login");
exit;
}
private function requireLogin(): void
{
if (!isset($_SESSION['user'])) {
header("Location: /login");
exit;
}
}
}
5. 视图文件
(1) views/layout.php
PHP
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title><?= $title ?? 'MyBlog' ?> — MyBlog</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; color: #333; }
header { border-bottom: 2px solid #4a90d9; padding-bottom: 10px; margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; }
.post-card { border: 1px solid #ddd; border-radius: 8px; padding: 15px; margin-bottom: 15px; }
.post-card h2 { margin-bottom: 8px; }
.post-card h2 a { color: #4a90d9; text-decoration: none; }
.post-meta { color: #999; font-size: 14px; margin-bottom: 10px; }
nav a { margin-left: 15px; color: #4a90d9; text-decoration: none; }
form div { margin-bottom: 12px; }
form label { display: block; margin-bottom: 4px; font-weight: bold; }
form input, form textarea { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 16px; }
form textarea { height: 200px; }
button { background: #4a90d9; color: white; border: none; padding: 10px 24px; border-radius: 4px; font-size: 16px; cursor: pointer; }
button:hover { background: #357abd; }
</style>
</head>
<body>
<header>
<h1>📝 <a href="/" style="text-decoration:none;color:#333">MyBlog</a></h1>
<nav>
<a href="/">首页</a>
<?php if (isset($_SESSION['user'])): ?>
<a href="/posts/create">写文章</a>
<span><?= htmlspecialchars($_SESSION['user']['username']) ?></span>
<a href="/logout">退出</a>
<?php else: ?>
<a href="/login">登录</a>
<a href="/register">注册</a>
<?php endif; ?>
</nav>
</header>
<main>
<?= $content ?? '' ?>
</main>
</body>
</html>
(2) views/posts/index.php
PHP
<?php $title = '文章列表'; ob_start(); ?>
<h2>全部文章</h2>
<?php if (empty($posts)): ?>
<p>还没有文章,成为第一个作者吧!</p>
<?php else: ?>
<?php foreach ($posts as $post): ?>
<div class="post-card">
<h2><a href="/posts/<?= $post['id'] ?>"><?= htmlspecialchars($post['title']) ?></a></h2>
<div class="post-meta">
作者:<?= htmlspecialchars($post['username']) ?> |
<?= date('Y-m-d', strtotime($post['created_at'])) ?>
</div>
<p><?= htmlspecialchars(substr(strip_tags($post['content']), 0, 200)) ?>...</p>
</div>
<?php endforeach; ?>
<?php endif; ?>
<?php $content = ob_get_clean(); require __DIR__ . '/../layout.php'; ?>
(3) views/posts/show.php
PHP
<?php $title = $post['title']; ob_start(); ?>
<article>
<h2><?= htmlspecialchars($post['title']) ?></h2>
<div class="post-meta">
作者:<?= htmlspecialchars($post['username']) ?> |
发布时间:<?= $post['created_at'] ?>
</div>
<div style="line-height:1.8;margin-top:20px">
<?= nl2br(htmlspecialchars($post['content'])) ?>
</div>
</article>
<p style="margin-top:30px"><a href="/posts">← 返回文章列表</a></p>
<?php $content = ob_get_clean(); require __DIR__ . '/../layout.php'; ?>
(4) views/auth/login.php
PHP
<?php $title = '登录'; ob_start(); ?>
<h2>登录</h2>
<form method="POST" action="/login">
<div>
<label>用户名</label>
<input type="text" name="username" required>
</div>
<div>
<label>密码</label>
<input type="password" name="password" required>
</div>
<button type="submit">登录</button>
</form>
<p style="margin-top:15px">还没有账号?<a href="/register">立即注册</a></p>
<?php $content = ob_get_clean(); require __DIR__ . '/../layout.php'; ?>
6. 数据库准备
SQL
CREATE DATABASE IF NOT EXISTS myblog CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE myblog;
-- 用户表(已有则跳过)
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
age INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 文章表
CREATE TABLE IF NOT EXISTS posts (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
title VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
status ENUM('draft', 'published') DEFAULT 'published',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
7. 文件结构创建命令
在 myphp/public/ 目录下运行:
BASH
# 创建目录结构
mkdir -p myblog/app/{Controllers,Models,Core}
mkdir -p myblog/views/{posts,auth}
mkdir -p myblog/public
# 创建 .htaccess(Apache URL 重写)
APACHE
# public/.htaccess
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]
❓ 常见问题
Q 为什么要一个入口文件 index.php 处理所有请求?
A 这叫"前端控制器模式"(Front Controller),是现代 PHP 框架(Laravel/Symfony)的标准架构。好处:统一鉴权、统一错误处理、一处配置 URL 路由。
Q 为什么用 output buffering(
ob_start/ob_get_clean)返回 HTML?A 控制器的职责是"返回值"而不是"echo HTML"。用 ob 机制让控制器返回字符串,最后由
Router::dispatch() 统一输出。方便后续做中间件、统一响应头等。❓ 常见问题
Q 这个概念和 XXX 有什么区别?
A 简洁对比两者的核心差异和使用场景。
📖 小节
- 项目架构:入口 → 路由 → 控制器 → 模型 → 视图
- Database 单例模式:全局共享一个 PDO 连接
- Router 动态路由:
/posts/{id}正则匹配 - MVC 分层:Controller 协调,Model 操作数据库,View 渲染 HTML
- Session 保持登录状态,未登录用户跳转到登录页
- output buffering 让控制器返回 HTML 字符串
📝 作业
- 按照上面代码在本地搭建博客系统,注册账号 → 登录 → 发布文章 → 查看文章。
- 给博客系统添加"我的文章"页面(只显示当前登录用户的文章)。
- 添加文章删除功能(只有文章作者能删除自己的文章)。