本案例展示一個極簡微博系統,涵蓋資料庫設計、PDO 安全查詢、MVC 分層、Bootstrap 5 響應式介面、分頁、搜尋、CSRF 保護。適合 PHP 初學者理解完整專案結構與最佳實踐。
microblog/
├── public/ # Web 根目錄
│ ├── index.php # 入口:文章列表
│ ├── create.php # 新增文章
│ ├── edit.php # 編輯文章
│ ├── delete.php # 刪除處理
│ ├── view.php # 文章詳情
│ ├── assets/ # CSS/JS/圖片
│ └── .htaccess # URL 重寫 (可選)
├── src/
│ ├── Controller/ # 控制器
│ ├── Model/ # 資料模型
│ ├── View/ # 視圖模板
│ └── Core/ # 核心類 (Database, Router, Session, CSRF)
├── config/
│ └── database.php # 資料庫設定
├── storage/ # 日誌、快取、上傳
├── vendor/ # Composer 依賴
├── composer.json
└── .env # 環境變數 (不納入版控)
CREATE DATABASE microblog CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE microblog;
CREATE TABLE posts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
hit_count INT UNSIGNED DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_created (created_at),
FULLTEXT INDEX ft_title_content (title, content)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
<?php
return [
'driver' => 'mysql',
'host' => $_ENV['DB_HOST'] ?? '127.0.0.1',
'port' => (int)($_ENV['DB_PORT'] ?? 3306),
'database' => $_ENV['DB_NAME'] ?? 'microblog',
'username' => $_ENV['DB_USER'] ?? 'root',
'password' => $_ENV['DB_PASS'] ?? '',
'charset' => 'utf8mb4',
'options' => [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
],
];
<?php
namespace Core;
use PDO;
class Database {
private static ?PDO $instance = null;
private function __construct() {}
private function __clone() {}
public function __wakeup() {}
public static function getInstance(): PDO {
if (self::$instance === null) {
$config = require __DIR__ . '/../../config/database.php';
$dsn = "{$config['driver']}:host={$config['host']};port={$config['port']};dbname={$config['database']};charset={$config['charset']}";
self::$instance = new PDO($dsn, $config['username'], $config['password'], $config['options']);
}
return self::$instance;
}
}
<?php
namespace Core;
class CSRF {
public static function generate(): string {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
public static function validate(string $token): bool {
return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
}
public static function input(): string {
return '<input type="hidden" name="csrf_token" value="' . self::generate() . '">';
}
}
<?php
namespace Model;
use Core\Database;
use PDO;
class Post {
private PDO $db;
public function __construct() {
$this->db = Database::getInstance();
}
public function all(int $page = 1, int $perPage = 10, string $keyword = ''): array {
$offset = ($page - 1) * $perPage;
$sql = "SELECT * FROM posts";
$params = [];
if ($keyword) {
$sql .= " WHERE MATCH(title, content) AGAINST(? IN NATURAL LANGUAGE MODE)";
$params[] = $keyword;
}
$sql .= " ORDER BY created_at DESC LIMIT ? OFFSET ?";
$params[] = $perPage;
$params[] = $offset;
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
public function count(string $keyword = ''): int {
$sql = "SELECT COUNT(*) FROM posts";
$params = [];
if ($keyword) {
$sql .= " WHERE MATCH(title, content) AGAINST(? IN NATURAL LANGUAGE MODE)";
$params[] = $keyword;
}
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return (int)$stmt->fetchColumn();
}
public function find(int $id): ?array {
$stmt = $this->db->prepare("SELECT * FROM posts WHERE id = ?");
$stmt->execute([$id]);
$row = $stmt->fetch();
return $row ?: null;
}
public function create(string $title, string $content): int {
$stmt = $this->db->prepare("INSERT INTO posts (title, content) VALUES (?, ?)");
$stmt->execute([$title, $content]);
return (int)$this->db->lastInsertId();
}
public function update(int $id, string $title, string $content): bool {
$stmt = $this->db->prepare("UPDATE posts SET title = ?, content = ?, updated_at = NOW() WHERE id = ?");
return $stmt->execute([$title, $content, $id]);
}
public function delete(int $id): bool {
$stmt = $this->db->prepare("DELETE FROM posts WHERE id = ?");
return $stmt->execute([$id]);
}
public function incrementHit(int $id): void {
$this->db->prepare("UPDATE posts SET hit_count = hit_count + 1 WHERE id = ?")->execute([$id]);
}
}
<?php
require_once __DIR__ . '/../vendor/autoload.php';
session_start();
use Model\Post;
use Core\CSRF;
$postModel = new Post();
$page = max(1, (int)($_GET['page'] ?? 1));
$keyword = trim($_GET['q'] ?? '');
$posts = $postModel->all($page, 10, $keyword);
$total = $postModel->count($keyword);
$totalPages = ceil($total / 10);
?>
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<title>微型博客</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>微型博客</h1>
<a href="create.php" class="btn btn-primary">發佈新文</a>
</div>
<form method="get" class="mb-4">
<div class="input-group">
<input type="search" name="q" class="form-control" placeholder="搜尋標題或內容..." value="<?= htmlspecialchars($keyword) ?>">
<button class="btn btn-outline-secondary" type="submit">搜尋</button>
</div>
</form>
<div class="row">
<?php foreach ($posts as $post): ?>
<div class="col-md-6 mb-3">
<div class="card h-100 shadow-sm">
<div class="card-body">
<h5 class="card-title"><a href="view.php?id=<?= $post['id'] ?>" class="text-dark text-decoration-none"><?= htmlspecialchars($post['title']) ?></a></h5>
<p class="card-text text-muted small"><?= mb_strimwidth(strip_tags($post['content']), 0, 120, '...') ?></p>
<div class="d-flex justify-content-between">
<small class="text-muted"><?= date('Y-m-d H:i', strtotime($post['created_at'])) ?></small>
<small>閱讀: <?= $post['hit_count'] ?></small>
</div>
</div>
<div class="card-footer bg-white border-0">
<a href="edit.php?id=<?= $post['id'] ?>" class="btn btn-sm btn-outline-secondary">編輯</a>
<form action="delete.php" method="post" class="d-inline" onsubmit="return confirm('確定刪除?')">
<?= CSRF::input() ?>
<input type="hidden" name="id" value="<?= $post['id'] ?>">
<button type="submit" class="btn btn-sm btn-outline-danger">刪除</button>
</form>
</div>
</div>
</div>
<?php endforeach; ?>
<?php if (empty($posts)): ?>
<div class="col-12 text-center text-muted py-5">暫無文章</div>
<?php endif; ?>
</div>
<nav aria-label="分頁">
<ul class="pagination justify-content-center">
<?php for ($i = 1; $i <= $totalPages; $i++): ?>
<li class="page-item <?= $i === $page ? 'active' : '' ?>">
<a class="page-link" href="?page=<?= $i ?>&q=<?= urlencode($keyword) ?>"><?= $i ?></a>
</li>
<?php endfor; ?>
</ul>
</nav>
</div>
</body>
</html>
<?php
require_once __DIR__ . '/../vendor/autoload.php';
session_start();
use Model\Post;
use Core\CSRF;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!CSRF::validate($_POST['csrf_token'] ?? '')) die('CSRF 驗證失敗');
$title = trim($_POST['title'] ?? '');
$content = trim($_POST['content'] ?? '');
if ($title && $content) {
$postModel = new Post();
$id = $postModel->create($title, $content);
header("Location: view.php?id=$id");
exit;
}
}
?>
<!DOCTYPE html>
<html lang="zh-TW">
<head><meta charset="UTF-8"><title>發佈新文</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"></head>
<body>
<div class="container py-4">
<h2>發佈新文章</h2>
<form method="post">
<?= CSRF::input() ?>
<div class="mb-3">
<label class="form-label">標題</label>
<input type="text" name="title" class="form-control" required maxlength="200">
</div>
<div class="mb-3">
<label class="form-label">內容</label>
<textarea name="content" class="form-control" rows="10" required></textarea>
</div>
<button type="submit" class="btn btn-success">發佈</button>
<a href="index.php" class="btn btn-secondary">取消</a>
</form>
</div>
</body>
</html>
public/index.php 統一入口,配合路由器分發。prepare() + execute(),防 SQL 注入。htmlspecialchars() 防 XSS,urlencode() 處理 URL 參數。max(1, (int)$_GET['page']) 防負數/非數字。.env,vlucas/phpdotenv 載入,不納入 Git。display_errors,記錄日誌至 storage/logs。