Dưới đây là hướng dẫn mã nguồn hoàn chỉnh để triển khai một dự án web tin tức đơn giản bằng PHP + MySQL (sử dụng PDO, prepared statements, password hashing, CSRF token, upload ảnh an toàn). Tôi cung cấp cấu trúc thư mục, câu lệnh SQL tạo database, và toàn bộ nội dung các file (bạn chỉ cần copy/paste vào máy và chạy). Hướng dẫn này thiết kế cho môi trường local (XAMPP, Laragon, MAMP) nhưng cũng dễ deploy lên VPS/hosting.
Lưu ý: đây là project mục đích học tập — cho production bạn cần thêm cấu hình bảo mật, HTTPS, hardening server, test, và backup.
1. Yêu cầu & chuẩn bị
-
PHP >= 7.2 (PDO, password_hash có sẵn)
-
MySQL / MariaDB
-
XAMPP / Laragon / MAMP hoặc server có PHP+MySQL
-
Thư mục project (ví dụ
htdocs/news-site)
2. Cấu trúc thư mục đề xuất
news-site/
├─ public/ # public root (webserver document root)
│ ├─ assets/
│ │ ├─ css/style.css
│ │ └─ uploads/ # nơi lưu ảnh (chmod writable)
│ ├─ index.php
│ ├─ article.php
│ └─ view.php
├─ admin/
│ ├─ login.php
│ ├─ logout.php
│ ├─ dashboard.php
│ ├─ article_create.php
│ ├─ article_edit.php
│ └─ article_delete.php
├─ config/
│ └─ config.php
├─ lib/
│ ├─ db.php
│ └─ helpers.php
└─ sql/
└─ schema.sql
Bạn có thể đặt public/ làm document root của webserver (an toàn hơn), hoặc mở toàn bộ folder và truy cập /public/index.php.
3. SQL: tạo database & bảng
Tạo file sql/schema.sql và chạy trong phpMyAdmin hoặc mysql client:
-- schema.sql
CREATE DATABASE IF NOT EXISTS newsdb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE newsdb;
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(150) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
fullname VARCHAR(150) NOT NULL,
role ENUM('admin','editor') DEFAULT 'editor',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE categories (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
slug VARCHAR(150) NOT NULL UNIQUE
);
CREATE TABLE articles (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
excerpt TEXT,
content LONGTEXT,
thumbnail VARCHAR(255),
category_id INT,
author_id INT,
status ENUM('draft','published') DEFAULT 'draft',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL,
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE tags (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
slug VARCHAR(150) NOT NULL UNIQUE
);
CREATE TABLE article_tag (
article_id INT NOT NULL,
tag_id INT NOT NULL,
PRIMARY KEY (article_id, tag_id),
FOREIGN KEY (article_id) REFERENCES articles(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
CREATE TABLE comments (
id INT AUTO_INCREMENT PRIMARY KEY,
article_id INT NOT NULL,
name VARCHAR(150) NOT NULL,
email VARCHAR(150) NOT NULL,
content TEXT NOT NULL,
status ENUM('pending','approved','spam') DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (article_id) REFERENCES articles(id) ON DELETE CASCADE
);
-- Insert a default admin
INSERT INTO users (email, password, fullname, role)
VALUES ('admin@example.com', '{REPLACE_WITH_HASH}', 'Site Admin', 'admin');
-- Replace {REPLACE_WITH_HASH} with the hash from PHP password_hash('yourpassword', PASSWORD_DEFAULT)
Hướng dẫn tạo password hash: Mở file PHP tạm:
<?php
echo password_hash('admin123', PASSWORD_DEFAULT);
Chạy file, copy kết quả và thay {REPLACE_WITH_HASH} trong SQL.
4. File cấu hình DB
config/config.php
<?php
// config/config.php
return [
'db' => [
'host' => '127.0.0.1',
'name' => 'newsdb',
'user' => 'root',
'pass' => '',
'charset' => 'utf8mb4',
],
'site' => [
'base_url' => 'http://localhost/news-site/public', // điều chỉnh theo môi trường
]
];
5. Lớp DB (PDO) và helper
lib/db.php
<?php
// lib/db.php
class DB {
private static $pdo = null;
public static function init($config) {
if (self::$pdo === null) {
$db = $config['db'];
$dsn = "mysql:host={$db['host']};dbname={$db['name']};charset={$db['charset']}";
self::$pdo = new PDO($dsn, $db['user'], $db['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false
]);
}
}
public static function pdo() {
return self::$pdo;
}
public static function fetchAll($sql, $params = []) {
$stmt = self::$pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
public static function fetch($sql, $params = []) {
$stmt = self::$pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetch();
}
public static function execute($sql, $params = []) {
$stmt = self::$pdo->prepare($sql);
return $stmt->execute($params);
}
public static function lastInsertId() {
return self::$pdo->lastInsertId();
}
}
lib/helpers.php
<?php
// lib/helpers.php
session_start();
function config() {
static $cfg = null;
if ($cfg === null) {
$cfg = require __DIR__ . '/../config/config.php';
}
return $cfg;
}
function base_url($path = '') {
$cfg = config();
return rtrim($cfg['site']['base_url'], '/') . '/' . ltrim($path, '/');
}
function e($str) {
return htmlspecialchars($str ?? '', ENT_QUOTES, 'UTF-8');
}
function slugify($text) {
$text = preg_replace('~[^\pL\d]+~u', '-', $text);
$text = iconv('utf-8', 'us-ascii//TRANSLIT', $text);
$text = preg_replace('~[^-\w]+~', '', $text);
$text = trim($text, '-');
$text = preg_replace('~-+~', '-', $text);
$text = strtolower($text);
if (empty($text)) {
return 'n-a';
}
return $text;
}
function csrf_token() {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function check_csrf($token) {
return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
}
function is_logged_in() {
return !empty($_SESSION['user_id']);
}
function require_login() {
if (!is_logged_in()) {
header('Location: ' . base_url('admin/login.php'));
exit;
}
}
6. Frontend: trang danh sách & bài viết
public/assets/css/style.css (một file cơ bản)
/* basic styles */
body { font-family: Arial, sans-serif; margin:0; padding:0; background:#f7f7f7; }
.container { max-width:1000px; margin:20px auto; background:#fff; padding:20px; box-shadow:0 2px 8px rgba(0,0,0,0.1);}
.header { display:flex; justify-content:space-between; align-items:center;}
.article { border-bottom:1px solid #eee; padding:15px 0;}
.article h2 { margin:0; font-size:1.25rem;}
.meta { color:#777; font-size:0.9rem;}
.thumbnail { max-width:200px; height:auto; float:left; margin-right:15px; }
.clear { clear:both; }
.btn { display:inline-block; padding:8px 12px; background:#007bff; color:#fff; border-radius:4px; text-decoration:none;}
.form-row { margin-bottom:10px; }
public/index.php – trang chủ/phan trang
<?php
require_once __DIR__ . '/../lib/helpers.php';
require_once __DIR__ . '/../lib/db.php';
DB::init(config());
$page = max(1, (int)($_GET['page'] ?? 1));
$perPage = 6;
$offset = ($page - 1) * $perPage;
$total = DB::fetch("SELECT COUNT(*) AS c FROM articles WHERE status='published'")['c'];
$articles = DB::fetchAll("SELECT a.*, u.fullname AS author, c.name AS category FROM articles a LEFT JOIN users u ON a.author_id = u.id LEFT JOIN categories c ON a.category_id = c.id WHERE a.status='published' ORDER BY a.created_at DESC LIMIT :limit OFFSET :offset", ['limit' => $perPage, 'offset' => $offset]);
// Note: PDO binding requires types; above we passed as params but could bindParam with PDO::PARAM_INT if needed.
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Trang tin - Home</title>
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
<div class="container">
<div class="header">
<h1><a href="<?= e(base_url('public/index.php')) ?>">My News</a></h1>
<div><a class="btn" href="<?= e(base_url('admin/login.php')) ?>">Admin</a></div>
</div>
<hr>
<?php foreach ($articles as $a): ?>
<div class="article">
<?php if (!empty($a['thumbnail'])): ?>
<img class="thumbnail" src="assets/uploads/<?= e($a['thumbnail']) ?>" alt="<?= e($a['title']) ?>">
<?php endif; ?>
<h2><a href="article.php?slug=<?= e($a['slug']) ?>"><?= e($a['title']) ?></a></h2>
<div class="meta">By <?= e($a['author'] ?? 'Unknown') ?> | <?= e($a['category'] ?? 'Uncategorized') ?> | <?= e($a['created_at']) ?></div>
<p><?= nl2br(e($a['excerpt'])) ?></p>
<a class="btn" href="article.php?slug=<?= e($a['slug']) ?>">Đọc tiếp</a>
<div class="clear"></div>
</div>
<?php endforeach; ?>
<div style="margin-top:20px;">
<?php
$totalPages = ceil($total / $perPage);
for ($i=1;$i<=$totalPages;$i++){
if ($i==$page) echo "<strong>$i</strong> ";
else echo "<a href='?page=$i'>$i</a> ";
}
?>
</div>
</div>
</body>
</html>
public/article.php – xem chi tiết bài
<?php
require_once __DIR__ . '/../lib/helpers.php';
require_once __DIR__ . '/../lib/db.php';
DB::init(config());
$slug = $_GET['slug'] ?? '';
$article = DB::fetch("SELECT a.*, u.fullname AS author, c.name AS category FROM articles a LEFT JOIN users u ON a.author_id = u.id LEFT JOIN categories c ON a.category_id = c.id WHERE a.slug = :slug AND a.status='published'", ['slug'=>$slug]);
if (!$article) {
http_response_code(404);
echo "Bài viết không tìm thấy";
exit;
}
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title><?= e($article['title']) ?></title>
<link rel="stylesheet" href="assets/css/style.css">
<meta name="description" content="<?= e(substr(strip_tags($article['excerpt'] ?? $article['content']),0,160)) ?>">
<!-- Open Graph -->
<meta property="og:title" content="<?= e($article['title']) ?>">
<meta property="og:description" content="<?= e(substr(strip_tags($article['excerpt'] ?? $article['content']),0,160)) ?>">
<?php if (!empty($article['thumbnail'])): ?>
<meta property="og:image" content="<?= e(base_url('public/assets/uploads/'.$article['thumbnail'])) ?>">
<?php endif; ?>
</head>
<body>
<div class="container">
<h1><?= e($article['title']) ?></h1>
<div class="meta">By <?= e($article['author'] ?? 'Unknown') ?> | <?= e($article['category'] ?? '') ?> | <?= e($article['created_at']) ?></div>
<?php if (!empty($article['thumbnail'])): ?>
<p><img src="assets/uploads/<?= e($article['thumbnail']) ?>" alt="" style="max-width:100%;"></p>
<?php endif; ?>
<div><?= $article['content'] /* content stored as HTML from editor */ ?></div>
<p><a href="index.php">Quay về trang chủ</a></p>
</div>
</body>
</html>
Lưu:
contentta giả sử được lưu là HTML (editor như TinyMCE) — hiển thị thận trọng: nếu hộp soạn cho phép HTML, cần sanitization (e.g. HTMLPurifier) trên input trước khi lưu.
7. Admin: login / dashboard / CRUD bài viết
admin/login.php
<?php
require_once __DIR__ . '/../lib/helpers.php';
require_once __DIR__ . '/../lib/db.php';
DB::init(config());
$err = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = $_POST['email'] ?? '';
$password = $_POST['password'] ?? '';
if ($email && $password) {
$user = DB::fetch("SELECT * FROM users WHERE email = :email", ['email'=>$email]);
if ($user && password_verify($password, $user['password'])) {
// login ok
$_SESSION['user_id'] = $user['id'];
$_SESSION['user_name'] = $user['fullname'];
header('Location: dashboard.php');
exit;
} else {
$err = 'Thông tin đăng nhập không chính xác';
}
} else {
$err = 'Vui lòng nhập email và mật khẩu';
}
}
?>
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Admin Login</title></head>
<body>
<div class="container">
<h2>Admin Login</h2>
<?php if ($err): ?><p style="color:red;"><?= e($err) ?></p><?php endif; ?>
<form method="post">
<div class="form-row"><label>Email:</label><input type="email" name="email" required></div>
<div class="form-row"><label>Password:</label><input type="password" name="password" required></div>
<button type="submit">Login</button>
</form>
<p><a href="<?= e(base_url('public/index.php')) ?>">Back to site</a></p>
</div>
</body>
</html>
admin/logout.php
<?php
require_once __DIR__ . '/../lib/helpers.php';
session_start();
session_destroy();
header('Location: login.php');
exit;
admin/dashboard.php (liệt kê bài, link create/edit/delete)
<?php
require_once __DIR__ . '/../lib/helpers.php';
require_once __DIR__ . '/../lib/db.php';
DB::init(config());
require_login();
$articles = DB::fetchAll("SELECT a.*, u.fullname as author, c.name as category FROM articles a LEFT JOIN users u ON a.author_id = u.id LEFT JOIN categories c ON a.category_id = c.id ORDER BY a.created_at DESC");
?>
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Admin Dashboard</title></head>
<body>
<div class="container">
<h2>Dashboard - Welcome <?= e($_SESSION['user_name']) ?></h2>
<p><a href="article_create.php">Create New Article</a> | <a href="logout.php">Logout</a></p>
<table border="1" cellpadding="6">
<tr><th>ID</th><th>Title</th><th>Category</th><th>Status</th><th>Actions</th></tr>
<?php foreach ($articles as $a): ?>
<tr>
<td><?= e($a['id']) ?></td>
<td><?= e($a['title']) ?></td>
<td><?= e($a['category']) ?></td>
<td><?= e($a['status']) ?></td>
<td>
<a href="article_edit.php?id=<?= e($a['id']) ?>">Edit</a> |
<a href="article_delete.php?id=<?= e($a['id']) ?>" onclick="return confirm('Delete?')">Delete</a>
</td>
</tr>
<?php endforeach; ?>
</table>
</div>
</body>
</html>
admin/article_create.php
<?php
require_once __DIR__ . '/../lib/helpers.php';
require_once __DIR__ . '/../lib/db.php';
DB::init(config());
require_login();
$err = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!check_csrf($_POST['csrf'] ?? '')) { $err = 'CSRF token invalid'; }
$title = trim($_POST['title'] ?? '');
$content = $_POST['content'] ?? '';
$excerpt = $_POST['excerpt'] ?? '';
$category_id = $_POST['category_id'] ?: null;
$status = $_POST['status'] ?? 'draft';
$slug = slugify($title);
// handle upload
$thumbnail = null;
if (!empty($_FILES['thumbnail']['name'])) {
$allowed = ['image/jpeg','image/png','image/gif'];
if (!in_array($_FILES['thumbnail']['type'], $allowed)) {
$err = 'Only JPG/PNG/GIF allowed';
} else {
$ext = pathinfo($_FILES['thumbnail']['name'], PATHINFO_EXTENSION);
$fname = time() . '_' . bin2hex(random_bytes(6)) . '.' . $ext;
$dst = __DIR__ . '/../public/assets/uploads/' . $fname;
if (!move_uploaded_file($_FILES['thumbnail']['tmp_name'], $dst)) {
$err = 'Upload failed';
} else {
$thumbnail = $fname;
}
}
}
if (!$err && $title) {
DB::execute("INSERT INTO articles (title, slug, excerpt, content, thumbnail, category_id, author_id, status) VALUES (:title, :slug, :excerpt, :content, :thumbnail, :category_id, :author_id, :status)", [
'title'=>$title, 'slug'=>$slug, 'excerpt'=>$excerpt, 'content'=>$content, 'thumbnail'=>$thumbnail,
'category_id'=>$category_id, 'author_id'=>$_SESSION['user_id'], 'status'=>$status
]);
header('Location: dashboard.php');
exit;
}
}
$categories = DB::fetchAll("SELECT * FROM categories ORDER BY name");
?>
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Create Article</title></head>
<body>
<div class="container">
<h2>Create Article</h2>
<?php if ($err) echo "<p style='color:red'>".e($err)."</p>"; ?>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf" value="<?= e(csrf_token()) ?>">
<div class="form-row"><label>Title</label><input type="text" name="title" required></div>
<div class="form-row"><label>Excerpt</label><textarea name="excerpt"></textarea></div>
<div class="form-row"><label>Content</label><textarea name="content" rows="10"></textarea></div>
<div class="form-row"><label>Thumbnail</label><input type="file" name="thumbnail"></div>
<div class="form-row"><label>Category</label><select name="category_id"><option value="">--None--</option>
<?php foreach($categories as $c) echo '<option value="'.e($c['id']).'">'.e($c['name']).'</option>'; ?>
</select></div>
<div class="form-row"><label>Status</label>
<select name="status"><option value="draft">Draft</option><option value="published">Published</option></select>
</div>
<button type="submit">Create</button>
</form>
<p><a href="dashboard.php">Back</a></p>
</div>
</body>
</html>
admin/article_edit.php and admin/article_delete.php follow the same security patterns: check login, check CSRF for POST actions, fetch record by id, allow update/delete using prepared statements. (Due to length, you can replicate create logic, pre-fill fields, and handle UPDATE and DELETE accordingly — keep the same validation and upload handling.)
8. Notes về upload & folder permissions
-
Tạo folder
public/assets/uploadsvà cấp quyền ghi:chmod 755hoặcchmod 775(tùy server). -
Kiểm tra MIME type và file size. Bạn có thể dùng
getimagesize()để validate ảnh.
9. An toàn & vận hành
-
Luôn dùng prepared statements (PDO) — đã áp dụng trong template.
-
Mã hoá mật khẩu bằng
password_hash()(đã dùng trong SQL seed). -
Dùng
htmlspecialchars()khi in ra giá trị không tin cậy (e()helper). -
CSRF token cho form admin (đã có).
-
Bật HTTPS trên production; set cookie flags
httponlyvàsecure. -
Log lỗi (file), không display errors trên production.
10. Triển khai và kiểm tra
-
Import
sql/schema.sqlvà tạo account admin (dùngpassword_hashđể tạo mật khẩu). -
Sửa
config/config.phpphù hợp host và base_url. -
Đặt document root trỏ vào
news-site/publichoặc dùnglocalhost/news-site/public/. -
Tạo folder upload
public/assets/uploadsvà chmod. -
Mở
public/index.phpđể xem trang chủ; đăng nhập admin vàoadmin/login.php, tạo bài.
11. Gợi ý mở rộng (khi đã hoàn thiện)
-
Thêm WYSIWYG (TinyMCE/CKEditor) cho content, kèm sanitize (HTMLPurifier).
-
Thêm tính năng comment (với moderation), search nâng cao (FULLTEXT), tag management.
-
Dùng Redis để cache danh sách bài phổ biến.
-
Tách model and repository, hoặc sử dụng framework (Laravel) khi dự án lớn.
-
Viết unit test/integration test cho module admin & article.
Kết luận — Triển khai nhanh & tiếp tục học
Đây là một bộ hướng dẫn mã nguồn đầy đủ, đủ để bạn tạo web tin tức cơ bản bằng PHP/MySQL: từ schema DB, lớp kết nối, helper, front-end hiển thị, đến admin panel với authentication, upload ảnh và security cơ bản. Bạn có thể copy toàn bộ file, tùy chỉnh config.php, import schema.sql, và chạy thử trên local.
Nếu muốn, tôi có thể:
-
Gửi thêm nội dung chi tiết file
article_edit.phpvàarticle_delete.phphoàn chỉnh. -
Viết README sẵn cho repo (chỉ dẫn deploy, seed admin).
-
Thêm ví dụ Ajax để publish/unpublish bài không reload trang.
Bạn muốn tôi gửi thêm file nào chi tiết hơn trước? (ví dụ article_edit.php, article_delete.php, hoặc script seed admin tự động với password hash).