首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> 網頁程式設計 >> PHP 程式設計 >> PHP 相關知識

PHP 微型博客入門案例:MVC 架構、PDO 資料庫、Bootstrap 介面

     編輯:PHP 相關知識

PHP 微型博客入門案例:MVC 架構、PDO 資料庫、Bootstrap 介面

本案例展示一個極簡微博系統,涵蓋資料庫設計、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                 # 環境變數 (不納入版控)

資料庫設計 (MySQL)

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;

核心類實作

config/database.php

<?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,
    ],
];

src/Core/Database.php (單例 PDO)

<?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;
    }
}

src/Core/CSRF.php

<?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() . '">';
    }
}

模型層

src/Model/Post.php

<?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]);
    }
}

控制器與視圖 (精簡展示)

public/index.php (列表 + 搜尋 + 分頁)

<?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>

public/create.php (新增)

<?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>

關鍵最佳實踐總結

  1. 單一入口/前端控制器:實際專案建議採用 public/index.php 統一入口,配合路由器分發。
  2. PDO 預處理語句:全程使用 prepare() + execute(),防 SQL 注入。
  3. 輸出轉義:htmlspecialchars() 防 XSS,urlencode() 處理 URL 參數。
  4. CSRF Token:所有狀態改變表單 (POST/PUT/DELETE) 必須驗證。
  5. 分頁參數白名單:max(1, (int)$_GET['page']) 防負數/非數字。
  6. 環境變數配置:資料庫憑證放 .env,vlucas/phpdotenv 載入,不納入 Git。
  7. 錯誤處理:生產環境關閉 display_errors,記錄日誌至 storage/logs。

擴展練習建議

  • 加入使用者註冊/登入/Session 認證、權限控制 (作者才能編輯/刪除)。
  • 實作標籤系統、分類、Markdown 編輯器 (EasyMDE)、圖片上傳。
  • 導入 PHPUnit 單元測試、PHPStan 靜態分析、GitHub Actions CI/CD。
  • 重構為 ThinkPHP 6 / Laravel 8 架構,體驗成熟框架優勢。
  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved