
用 PHP 搭建新闻网站源码:文章发布与分类功能实现
新闻网站的基础功能通常包括文章录入、分类管理、文章列表和详情展示。它们看起来只是几张表和几个表单,实际开发时却涉及数据完整性、权限控制、HTML 转义、请求伪造防护等问题。只依赖前端校验或把表单数据直接写入数据库,容易造成数据错误或安全漏洞。
本文用 PHP 8.1 及以上版本、PDO 和 MySQL 8.0 演示一个精简但可运行的实现。示例将文章内容按纯文本保存,展示时转义 HTML;发布接口要求登录,并使用 CSRF 令牌保护。它适合小型内容站点的功能起步,不包含完整的后台账号管理、富文本编辑器、审核工作流和文件上传。
一、功能范围与数据流
本文实现的功能包括:
- 通过数据库保存新闻分类和文章。
- 管理员登录后创建文章,选择分类并设置草稿或发布状态。
- 公众只能看到已发布文章,且只能看到仍然存在的分类。
- 文章标题、摘要和内容都按普通文本处理,输出时进行 HTML 转义。
基本数据流是:浏览器提交表单,PHP 检查登录状态、CSRF 令牌和字段规则,再通过 PDO 预处理语句写入数据库;读取文章时,页面只查询允许公开的数据,并在输出前转义文本。
请注意,示例中的登录检查只依赖会话变量 $_SESSION['user_id']。实际部署前必须接入可靠的登录系统,并确保该变量只能由服务端认证流程设置。隐藏表单、前端按钮或“只有管理员知道的 URL”都不能代替权限校验。
二、创建数据表
先创建数据库和两张表。分类名称设为唯一值;文章通过 category_id 引用分类。外键使用 RESTRICT,意味着分类仍被文章使用时,数据库会拒绝删除该分类,避免文章变成没有有效分类的数据。
CREATE DATABASE news_demo
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE news_demo;
CREATE TABLE categories (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(80) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_categories_name (name)
) ENGINE=InnoDB;
CREATE TABLE articles (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
category_id BIGINT UNSIGNED NOT NULL,
title VARCHAR(200) NOT NULL,
summary VARCHAR(500) NOT NULL DEFAULT '',
content MEDIUMTEXT NOT NULL,
status ENUM('draft', 'published') NOT NULL DEFAULT 'draft',
published_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_articles_public_list (status, published_at, id),
KEY idx_articles_category (category_id),
CONSTRAINT fk_articles_category
FOREIGN KEY (category_id) REFERENCES categories(id)
ON DELETE RESTRICT
) ENGINE=InnoDB;
VARCHAR(200)、VARCHAR(500) 和 MEDIUMTEXT 是数据库字段类型和容量边界,不代表 PHP 已经完成输入校验。PHP 仍需拒绝超出业务规则的值。MySQL 的字符串长度通常按字符处理,但不同字符集、排序规则和存储类型有各自的限制;不要把字段容量当成前端提示的唯一依据。
示例未使用 CHECK 约束来限制文章状态,因为部署环境的数据库版本和约束执行行为需要明确核实。这里通过 PHP 白名单限制状态,并使用 ENUM 约束状态取值。生产项目也可以将状态改为数值或独立状态表,但应确保数据库和应用的允许值保持一致。
三、连接数据库与公共函数
建立 config.php。数据库账号密码应来自环境变量或不纳入版本控制的配置文件,不能提交到公开代码仓库。PDO 设置异常模式,数据库连接使用预处理语句;预处理语句有助于避免 SQL 注入,但不会自动转义输出 HTML,也不会代替业务校验。
<?php
declare(strict_types=1);
session_start();
$dsn = 'mysql:host=127.0.0.1;dbname=news_demo;charset=utf8mb4';
$dbUser = getenv('NEWS_DB_USER') ?: 'news_app';
$dbPass = getenv('NEWS_DB_PASS') ?: '';
$pdo = new PDO($dsn, $dbUser, $dbPass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
再建立 functions.php。htmlspecialchars 用于在 HTML 文本上下文中编码特殊字符,降低用户输入被浏览器解释为标签或脚本的风险。它不是对所有输出场景通用的编码函数:若把数据放到 JavaScript、CSS 或 URL 中,需要针对对应上下文处理。本文只把数据输出到 HTML 文本节点和已正确构造的属性位置。
<?php
declare(strict_types=1);
function e(?string $value): string
{
return htmlspecialchars(
$value ?? '',
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
}
function requireLogin(): void
{
if (empty($_SESSION['user_id'])) {
http_response_code(403);
exit('请先登录后再操作。');
}
}
function csrfToken(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function verifyCsrfToken(?string $token): bool
{
return is_string($token)
&& isset($_SESSION['csrf_token'])
&& hash_equals($_SESSION['csrf_token'], $token);
}
CSRF 令牌用于确认表单请求来自当前会话中生成的页面,帮助防范攻击者诱使已登录用户提交请求。它不能证明用户有权限,也不能防止 XSS。涉及状态变更的操作应使用 POST,并同时进行身份和权限检查。会话 Cookie 还应在 HTTPS 环境中设置 Secure、HttpOnly 和合适的 SameSite 属性。
四、准备分类数据
分类管理可以单独做一个后台页面。为了让发布流程可运行,先插入几条分类:
INSERT INTO categories (name) VALUES
('国内'),
('国际'),
('科技');
正式的分类新增接口也必须要求登录和相应的管理权限,并使用预处理语句。分类名称要做长度、空值和重复检查;数据库的唯一索引是防止并发请求写入重复名称的重要兜底。用户看到的重复名称错误应友好处理,不要直接展示数据库异常或连接信息。
五、制作文章发布表单
创建 article_create.php。分类列表由数据库读取,不应相信浏览器传来的分类名称。表单提交分类 ID,服务端稍后仍会重新确认该 ID 对应的分类存在。
<?php
require __DIR__ . '/config.php';
require __DIR__ . '/functions.php';
requireLogin();
$categories = $pdo->query(
'SELECT id, name FROM categories ORDER BY name'
)->fetchAll();
if (!$categories) {
exit('请先创建至少一个分类。');
}
?>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>发布文章</title>
</head>
<body>
<h1>发布文章</h1>
<form method="post" action="article_store.php">
<input type="hidden" name="csrf_token"
value="<?= e(csrfToken()) ?>">
<label>标题
<input type="text" name="title" maxlength="200" required>
</label>
<label>分类
<select name="category_id" required>
<option value="">请选择</option>
<?php foreach ($categories as $category): ?>
<option value="<?= (int) $category['id'] ?>">
<?= e($category['name']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label>摘要
<textarea name="summary" maxlength="500"></textarea>
</label>
<label>正文
<textarea name="content" rows="14" required></textarea>
</label>
<label>状态
<select name="status">
<option value="draft">保存为草稿</option>
<option value="published">立即发布</option>
</select>
</label>
<button type="submit">保存文章</button>
</form>
</body>
</html>
HTML 的 maxlength 和 required 可以改善填写体验,但可以被用户绕过,不能代替服务端校验。若表单开放给不可信用户,还要考虑请求体大小限制、速率限制和滥用防护。正文较大时,应在 Web 服务器和 PHP 配置中设置合理的请求大小上限;具体数值取决于部署需求,不存在适合所有站点的固定值。
六、校验并保存文章
创建 article_store.php。服务端先确认请求方法和登录状态,再校验 CSRF 令牌、标题、分类 ID、摘要、正文和状态。分类 ID 是客户端传来的值,因此必须检查格式并查询数据库确认分类存在。SQL 中的表名和列名是固定的,只有数据值使用占位符;参数占位符不能用来绑定列名或排序方向。
<?php
declare(strict_types=1);
require __DIR__ . '/config.php';
require __DIR__ . '/functions.php';
requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
header('Allow: POST');
exit('仅支持 POST 请求。');
}
if (!verifyCsrfToken($_POST['csrf_token'] ?? null)) {
http_response_code(400);
exit('表单已失效,请返回页面重新提交。');
}
$title = trim((string) ($_POST['title'] ?? ''));
$summary = trim((string) ($_POST['summary'] ?? ''));
$content = trim((string) ($_POST['content'] ?? ''));
$categoryRaw = $_POST['category_id'] ?? '';
$status = $_POST['status'] ?? '';
if ($title === '' || mb_strlen($title, 'UTF-8') > 200) {
http_response_code(422);
exit('标题不能为空,且不能超过 200 个字符。');
}
if (mb_strlen($summary, 'UTF-8') > 500) {
http_response_code(422);
exit('摘要不能超过 500 个字符。');
}
if ($content === '') {
http_response_code(422);
exit('正文不能为空。');
}
if (!is_string($categoryRaw) || !ctype_digit($categoryRaw)) {
http_response_code(422);
exit('分类参数无效。');
}
$categoryId = (int) $categoryRaw;
if ($categoryId < 1) {
http_response_code(422);
exit('分类参数无效。');
}
$allowedStatuses = ['draft', 'published'];
if (!is_string($status) || !in_array($status, $allowedStatuses, true)) {
http_response_code(422);
exit('文章状态无效。');
}
$checkCategory = $pdo->prepare(
'SELECT id FROM categories WHERE id = :id'
);
$checkCategory->execute(['id' => $categoryId]);
if (!$checkCategory->fetch()) {
http_response_code(422);
exit('所选分类不存在。');
}
$publishedAt = $status === 'published' ? date('Y-m-d H:i:s') : null;
$insert = $pdo->prepare(
'INSERT INTO articles
(category_id, title, summary, content, status, published_at)
VALUES
(:category_id, :title, :summary, :content, :status, :published_at)'
);
$insert->execute([
'category_id' => $categoryId,
'title' => $title,
'summary' => $summary,
'content' => $content,
'status' => $status,
'published_at' => $publishedAt,
]);
header('Location: article_list.php', true, 303);
exit;
mb_strlen 按字符数检查标题和摘要,需要 PHP 的 mbstring 扩展。部署时应确认扩展已启用;如果没有启用,代码会因函数不存在而失败。不能简单用 strlen 替代,因为 UTF-8 中文字符通常占多个字节,strlen 统计的是字节数。
这里对文章正文没有设置应用层字符数上限,数据库字段为 MEDIUMTEXT,但实际可接收的数据还受 PHP、Web 服务器、数据库和网络配置影响。若业务需要正文上限,应明确单位是字符还是字节,并在服务端校验,同时将限制与数据库容量、请求大小配置协调起来。
保存发布时间时,示例使用 PHP 服务器当前时间。生产环境应统一 PHP 和数据库时区,通常采用 UTC 存储、展示时再转换为站点时区。若文章从草稿改为发布、从发布改回草稿,发布时间的更新规则也应由业务明确定义;本示例只处理首次创建。
七、显示公开文章列表
创建 article_list.php。列表只查询已发布文章,并通过内连接确保关联分类仍存在。文章标题、摘要和分类名输出前调用 e()。分页参数应限制为正整数,并在 SQL 中采用整数形式;以下示例暂不分页,数据量增长后不应无限制读取全站文章。
<?php
require __DIR__ . '/config.php';
require __DIR__ . '/functions.php';
$stmt = $pdo->query(
'SELECT a.id, a.title, a.summary, a.published_at,
c.name AS category_name
FROM articles AS a
INNER JOIN categories AS c ON c.id = a.category_id
WHERE a.status = \'published\'
AND a.published_at IS NOT NULL
ORDER BY a.published_at DESC, a.id DESC
LIMIT 50'
);
$articles = $stmt->fetchAll();
?>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>新闻列表</title>
</head>
<body>
<h1>新闻列表</h1>
<?php if (!$articles): ?>
<p>暂时没有已发布的文章。</p>
<?php else: ?>
<ul>
<?php foreach ($articles as $article): ?>
<li>
<a href="article.php?id=<?= (int) $article['id'] ?>">
<?= e($article['title']) ?>
</a>
<span>分类:<?= e($article['category_name']) ?></span>
<p><?= e($article['summary']) ?></p>
<time><?= e($article['published_at']) ?></time>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</body>
</html>
LIMIT 50 是示例中的固定上限,不是数据库或 PHP 的通用限制。正式分页可以使用 LIMIT :limit OFFSET :offset,并将经过严格范围校验的整数绑定为整数类型,或者在验证后转成整数再拼入 SQL。不可直接把未经校验的请求参数拼接到查询语句中。
八、显示文章详情
创建 article.php。查询条件同时限制文章 ID 和发布状态,避免公众通过修改 URL 读取草稿。ID 虽然是数字,也不能因为“看起来不像 SQL”就直接拼接进 SQL。
<?php
require __DIR__ . '/config.php';
require __DIR__ . '/functions.php';
$idRaw = $_GET['id'] ?? '';
if (!is_string($idRaw) || !ctype_digit($idRaw)) {
http_response_code(404);
exit('文章不存在。');
}
$id = (int) $idRaw;
if ($id < 1) {
http_response_code(404);
exit('文章不存在。');
}
$stmt = $pdo->prepare(
'SELECT a.title, a.summary, a.content, a.published_at,
c.name AS category_name
FROM articles AS a
INNER JOIN categories AS c ON c.id = a.category_id
WHERE a.id = :id
AND a.status = \'published\'
AND a.published_at IS NOT NULL
LIMIT 1'
);
$stmt->execute(['id' => $id]);
$article = $stmt->fetch();
if (!$article) {
http_response_code(404);
exit('文章不存在。');
}
?>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title><?= e($article['title']) ?></title>
</head>
<body>
<article>
<p>分类:<?= e($article['category_name']) ?></p>
<h1><?= e($article['title']) ?></h1>
<time><?= e($article['published_at']) ?></time>
<p><?= e($article['summary']) ?></p>
<div><?= nl2br(e($article['content']), false) ?></div>
</article>
</body>
</html>
nl2br 将换行符转换成 HTML 换行标签。这里先调用 e() 再换行,因此正文中的尖括号仍会作为文本显示,而不会成为可执行 HTML。这适用于纯文本编辑体验。若要支持富文本,不能直接移除转义;应使用经过维护的 HTML 白名单净化器,并明确允许的标签、属性和 URL 协议。
九、分类筛选的实现思路
分类列表页通常先查询有效分类,再按分类 ID 查询文章。查询文章时要同时限制状态和分类 ID,避免草稿泄露。示例查询如下:
<?php
$categoryId = (int) $validatedCategoryId;
$stmt = $pdo->prepare(
'SELECT a.id, a.title, a.summary, a.published_at,
c.name AS category_name
FROM articles AS a
INNER JOIN categories AS c ON c.id = a.category_id
WHERE a.category_id = :category_id
AND a.status = \'published\'
AND a.published_at IS NOT NULL
ORDER BY a.published_at DESC, a.id DESC
LIMIT 50'
);
$stmt->execute(['category_id' => $categoryId]);
$articles = $stmt->fetchAll();
$validatedCategoryId 不能直接来自未检查的请求值;应像详情页一样检查它是字符串数字、转换后大于零,并确认分类存在。即便查询使用了预处理语句,输入仍需要验证,因为参数化解决的是 SQL 数据边界问题,不负责业务规则、授权或错误提示。
十、常见错误与关键边界
- 只做前端校验。浏览器校验可以被绕过。标题长度、状态白名单、分类存在性和登录权限都必须在服务端验证。
- 把用户数据直接拼进 SQL。预处理语句应覆盖所有外部数据值。动态表名或排序字段不能作为普通参数绑定,必须从服务端固定白名单中选择。
- 误以为预处理语句能防 XSS。数据库写入和页面输出是不同安全边界。标题、分类名、摘要和正文在输出到 HTML 时都需要按上下文处理。
- 直接把正文当富文本输出。未经净化的 HTML 可能包含脚本或危险属性。本文采用纯文本展示;富文本需要专门的白名单净化流程。
- 只检查文章是否存在,不检查是否公开。详情查询必须带上发布状态条件。后台预览草稿应使用单独的、受权限保护的路由。
- 仅依赖分类下拉框。客户端提交的分类 ID 可被随意修改。服务端要验证分类存在且用户有权使用该分类,数据库外键则提供最后一道数据完整性保障。
- 分类删除没有明确策略。本文用外键拒绝删除仍有关联文章的分类。若业务希望删除分类,应先要求管理员迁移文章,或设计明确的归档策略,不应悄悄留下无效关联。
- 重复提交导致重复文章。发布接口可增加幂等令牌或业务去重机制;浏览器成功提交后使用 303 跳转,可避免刷新时重复提交表单,但不能单独解决所有重复请求问题。
- 把异常详情返回给公众。数据库连接错误和 SQL 异常应记录到受控日志,生产页面只返回通用错误信息。日志中也要避免记录密码、会话令牌等敏感内容。
- 忽略时区和分页。时间需要统一管理;文章量增大后应分页并检查查询计划。索引是否合适要结合真实查询和数据规模验证,不能仅凭字段名称推断性能。
十一、部署前检查
上线前至少检查以下事项:
- PHP 已启用 PDO MySQL 驱动和
mbstring,版本满足代码使用的语法要求。 - 数据库使用
utf8mb4,应用账号仅拥有所需的读写权限,不使用数据库管理员账号连接网站。 - 后台发布接口要求登录,并在每个请求中检查实际权限;需要时区分编辑、发布和分类管理权限。
- 全站启用 HTTPS,合理设置会话 Cookie 属性,并在登录后更新会话 ID,降低会话固定风险。
- 生产环境关闭错误详情显示,配置日志、备份和恢复流程,并测试分类外键与数据迁移行为。
- 对列表实现分页,对公开接口考虑访问频率限制;对后台提交限制请求大小,避免异常大请求消耗资源。
至此,文章从后台表单进入数据库,再通过公开列表和详情页展示的基础链路已经完整。这个结构将数据校验、权限判断、数据库访问和输出编码分别处理,便于后续添加分页、文章编辑、分类筛选和审核流程。扩展功能时,应继续遵守同一原则:请求参数不可信,写操作要授权,SQL 使用参数化查询,输出根据目标上下文进行编码。





