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 简洁对比两者的核心差异和使用场景。

📖 小节

📝 作业

  1. 按照上面代码在本地搭建博客系统,注册账号 → 登录 → 发布文章 → 查看文章。
  2. 给博客系统添加"我的文章"页面(只显示当前登录用户的文章)。
  3. 添加文章删除功能(只有文章作者能删除自己的文章)。
Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏