Sau khi hoàn thành module Categories, chúng ta sẽ xây dựng chức năng quan trọng nhất của Blog CMS: quản lý bài viết (Posts).
Khác với Category chỉ có vài trường dữ liệu, Post sẽ liên kết với nhiều bảng khác nhau như Category, User, đồng thời hỗ trợ upload hình ảnh, kiểm tra dữ liệu đầu vào và hiển thị danh sách bài viết chuyên nghiệp.
Đây cũng là module có số lượng kiến thức nhiều nhất trong toàn bộ dự án.
Mục tiêu bài học
Sau bài này bạn sẽ thực hiện được:
CRUD bài viết
Upload ảnh đại diện
Chọn Category
Gán tác giả (User)
Validation dữ liệu
Hiển thị Relationship
Tự động tạo slug
Phân trang dữ liệu
Kết quả sau bài học
Danh sách bài viết sẽ hiển thị như sau:
| Ảnh | Tiêu đề | Category | Tác giả | Ngày đăng | Thao tác |
|---|
| ✔ | Laravel 12 mới có gì? | Laravel | Admin | 07/08/2026 | Edit / Delete |
1. Cấu trúc Database
Bảng posts
| Field | Kiểu |
|---|
| id | bigint |
| user_id | foreignId |
| category_id | foreignId |
| title | string |
| slug | string |
| image | string |
| excerpt | text |
| content | longText |
| created_at | timestamp |
Quan hệ
User
│
├──────< Posts >────── Category
Một User có nhiều Post.
Một Category có nhiều Post.
Một Post chỉ thuộc một User và một Category.
Chú ý:
1. Sửa migration create_posts_table
Đưa luôn cấu trúc hoàn chỉnh vào migration tạo posts:
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')
->constrained()
->cascadeOnDelete();
$table->foreignId('category_id')
->nullable()
->constrained()
->nullOnDelete();
$table->string('title');
$table->string('slug')
->unique();
$table->string('image')
->nullable();
$table->text('excerpt')
->nullable();
$table->longText('content');
$table->enum('status', [
'draft',
'published',
'hidden',
'scheduled'
])->default('draft');
$table->timestamp('published_at')->nullable();
$table->timestamps();
});
Như vậy bạn không cần 3 migration riêng để:
-
thêm
category_id
-
thêm
image
-
thêm
slug
-
thêm
excerpt
nữa, nếu đây vẫn là database đang phát triển và chưa cần bảo toàn dữ liệu production.
2. Xóa các migration bổ sung cũ
Nếu bạn đã có các migration kiểu:
add_category_id_to_posts_table
add_image_to_posts_table
add_slug_and_excerpt_to_posts_table
thì có thể xóa chúng sau khi đã đưa cấu trúc cuối cùng vào migration create_posts_table.
Mục tiêu là migration tạo bảng posts ngay từ đầu đã hoàn chỉnh.
3. Sửa Post model
app/Models/Post.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
protected $fillable = [
'user_id',
'category_id',
'title',
'slug',
'image',
'excerpt',
'content',
'status',
'published_at',
];
protected $casts = [
'published_at' => 'datetime',
];
}
4. Seeder sửa Factory
<?php
namespace Database\Factories;
use App\Models\Model;
use App\Models\User;
use App\Models\Category;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Model>
*/
class PostFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$status = fake()->randomElement([
'draft',
'published',
'hidden',
'scheduled',
]);
return [
// Định nghĩa dữ liệu mẫu
'title' => fake()->sentence(),
'slug' => fake()->unique()->slug(),
'image' => fake()->imageUrl(1200, 800),
'excerpt' => fake()->paragraph(),
'content' => fake()->paragraphs(5, true),
'user_id' => fn () =>
User::query()->inRandomOrder()->value('id'),
'category_id' => fn () =>
Category::query()->inRandomOrder()->value('id'),
'status' => $status,
'published_at' => match ($status) {
'published' =>
fake()->dateTimeBetween('-1 year', 'now'),
'scheduled' =>
fake()->dateTimeBetween('now', '+1 month'),
default => null,
},
];
}
}
Chú ý: phải tạo bảng chứa khóa ngoại trước nếu không sẽ báo lỗi.
thứ tự migration nên là:
1. create_countries_table
2. create_users_table
3. create_categories_table
4. create_posts_table
Sau đó chạy:
php artisan migrate:fresh --seed
2. Relationship
User.php
public function posts()
{
return $this->hasMany(Post::class);
}
Category.php
public function posts()
{
return $this->hasMany(Post::class);
}
Post.php
public function category()
{
return $this->belongsTo(Category::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
Đây là ba relationship được sử dụng xuyên suốt dự án.
3. Resource Controller
Tạo controller
php artisan make:controller PostController --resource
Đăng ký route
Route::resource('posts', PostController::class);
Laravel sẽ tạo sẵn:
index
create
store
show
edit
update
destroy
4. Hiển thị danh sách bài viết
Controller
public function index()
{
//dd(Auth::user());
//lấy sẵn user để sau này chặn ngay từ view chỉ có admin được xem all posts
$user = Auth::user();
$posts = Post::with(['category','user'])
->latest()
->paginate(10);
return view('posts.index', compact('posts'));
}
Điểm quan trọng
with()
Laravel sẽ eager loading.
Không xảy ra lỗi N+1 Query.
Chú ý: nhớ kiểm tra route list trước coi đủ 7 món hay không.
php artisan optimize:clear
Kiểm tra:
php artisan route:list --name=posts
trong x-app-layout đã có sẵn thư viên datatables để xử lý gọn phần bảng
do file views/layouts/app.blade.php đã nạp đủ thư viện
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name', 'Laravel') }}</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet">
<link rel="stylesheet"
href="https://cdn.datatables.net/2.3.4/css/dataTables.dataTables.min.css">
<!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="font-sans antialiased">
<div class="min-h-screen bg-gray-100">
@include('layouts.navigation')
<!-- Page Heading -->
@isset($header)
<header class="bg-white shadow">
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
{{ $header }}
</div>
</header>
@endisset
<!-- Page Content -->
<main>
{{ $slot }}
</main>
</div>
</body>
</html>
File resources/js/app.js đã import đủ và xử lý JavaScript.
import $ from 'jquery';
window.$ = $;
window.jQuery = $;
import DataTable from 'datatables.net';
import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();
//cáu hình cho table posts index blade
const table = document.querySelector('#postsTable');
if (table) {
new DataTable(table, {
language: {
search: "🔍 Tìm kiếm:",
lengthMenu: "Hiển thị _MENU_ dòng",
info: "Hiển thị _START_ đến _END_ / _TOTAL_",
paginate: {
first: "Đầu",
last: "Cuối",
next: "Sau",
previous: "Trước"
},
zeroRecords: "Không tìm thấy dữ liệu",
emptyTable: "Chưa có dữ liệu"
}
});
}
File bladeposts.index
<x-app-layout>
<div class="py-6">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold text-gray-800">
Quản lý bài viết
</h2>
<p class="mt-1 text-sm text-gray-500">
Danh sách tất cả bài viết trong hệ thống
</p>
</div>
<a href="{{ route('posts.create') }}"
class="inline-flex items-center rounded-lg bg-indigo-600 px-4 py-2.5
text-sm font-semibold text-white shadow-sm
hover:bg-indigo-700">
+ Thêm bài viết
</a>
</div>
<!-- Card -->
<div class="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-200">
<!-- Card Header -->
<div class="border-b border-gray-200 px-6 py-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-800">
Danh sách bài viết
</h3>
<p class="text-sm text-gray-500">
Tổng cộng {{ $posts->total() }} bài viết
</p>
</div>
</div>
</div>
<!-- Table -->
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Tiêu đề
</th>
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Ngày
</th>
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Tác giả
</th>
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Danh mục
</th>
<th class="px-6 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500">
Thao tác
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
@forelse($posts as $post)
<tr class="hover:bg-gray-50">
{{-- TITLE --}}
<td class="px-6 py-4">
<a href="{{ route('posts.show', $post) }}"
class="font-semibold text-gray-800 hover:text-indigo-600">
{{ $post->title }}
</a>
</td>
{{-- DATE --}}
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
{{ $post->created_at->format('d/m/Y') }}
</td>
{{-- AUTHOR --}}
<td class="whitespace-nowrap px-6 py-4">
@if($post->user)
<span class="text-sm font-medium text-gray-700">
{{ $post->user->name }}
</span>
@else
<span class="text-sm text-gray-400">
—
</span>
@endif
</td>
{{-- CATEGORY --}}
<td class="whitespace-nowrap px-6 py-4">
@if($post->category)
<span class="inline-flex rounded-full
bg-blue-100 px-3 py-1
text-xs font-medium text-blue-700">
{{ $post->category->name }}
</span>
@else
<span class="text-sm text-gray-400">
—
</span>
@endif
</td>
{{-- ACTIONS --}}
<td class="whitespace-nowrap px-6 py-4">
<div class="flex justify-end gap-2">
{{-- VIEW --}}
<a href="{{ route('posts.show', $post) }}"
class="rounded-lg bg-gray-100 px-3 py-2
text-sm font-medium text-gray-700
hover:bg-gray-200">
Xem
</a>
{{-- EDIT --}}
<a href="{{ route('posts.edit', $post) }}"
class="rounded-lg bg-blue-100 px-3 py-2
text-sm font-medium text-blue-700
hover:bg-blue-200">
Sửa
</a>
{{-- DELETE --}}
<form action="{{ route('posts.destroy', $post) }}"
method="POST"
onsubmit="return confirm('Bạn có chắc muốn xóa bài viết này?');">
@csrf
@method('DELETE')
<button type="submit"
class="rounded-lg bg-red-100 px-3 py-2
text-sm font-medium text-red-700
hover:bg-red-200">
Xóa
</button>
</form>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="5"
class="px-6 py-12 text-center text-sm text-gray-400">
Chưa có bài viết nào.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<!-- Pagination -->
@if($posts->hasPages())
<div class="border-t border-gray-200 bg-white px-6 py-4">
{{ $posts->links() }}
</div>
@endif
</div>
</div>
</div>
</x-app-layout>
5. Hiển thị Relationship
Trong Blade
{{ $post->category->name }}
{{ $post->user->name }}
Không cần viết Query JOIN.
Đó chính là sức mạnh của Eloquent ORM.
6. Form tạo bài viết
Form sẽ có:
Tiêu đề
Slug
Category
Hình ảnh
Mô tả ngắn
Nội dung
Category
<select name="category_id">
@foreach($categories as $category)
<option value="{{ $category->id }}">
{{ $category->name }}
</option>
@endforeach
</select>
Laravel sẽ lấy toàn bộ Category để người dùng lựa chọn.
Bước 1: sửa hàm store trong PostController
public function store(Request $request)
{
$request->validate(
[
'title' => 'required|max:255',
'slug' => [
'required',
'max:255',
'unique:posts,slug',
],
'excerpt' => 'nullable',
'content' => 'required|string',
'category_id' => [
'nullable',
'exists:categories,id',
],
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
'image' => [
'nullable',
'image',
'mimes:jpg,jpeg,png,webp',
'max:2048',
],
'status' => [
'required',
'in:draft,published,hidden,scheduled',
],
'published_at' => 'nullable|date',
],
[
'title.required' => 'Vui lòng nhập tiêu đề.',
'title.max' => 'Tiêu đề tối đa 255 ký tự.',
'slug.required' => 'Vui lòng nhập slug.',
'slug.unique' => 'Slug này đã tồn tại.',
'slug.max' => 'Slug tối đa 255 ký tự.',
'content.required' => 'Vui lòng nhập nội dung.',
'content.min' => 'Nội dung tối thiểu 20 ký tự.',
'category_id.exists' => 'Danh mục không tồn tại.',
'tags.array' => 'Tags không hợp lệ.',
'tags.*.exists' => 'Tag không tồn tại.',
'image.image' => 'File tải lên phải là hình ảnh.',
'image.mimes' => 'Ảnh phải có định dạng jpg, jpeg, png hoặc webp.',
'image.max' => 'Ảnh tối đa 2MB.',
'status.required' => 'Vui lòng chọn trạng thái.',
'status.in' => 'Trạng thái không hợp lệ.',
'published_at.date' => 'Ngày xuất bản không hợp lệ.',
]
);
/*
|--------------------------------------------------------------------------
| Lưu ảnh
|--------------------------------------------------------------------------
*/
$image = null;
if ($request->hasFile('image')) {
$image = $request
->file('image')
->store(
'posts',
'public'
);
}
/*
|--------------------------------------------------------------------------
| Published At
|--------------------------------------------------------------------------
*/
$publishedAt = $request->published_at;
// Nếu chọn Published nhưng không nhập ngày
// thì lấy thời điểm hiện tại
if (
$request->status === 'published'
&& empty($publishedAt)
) {
$publishedAt = now();
}
/*
|--------------------------------------------------------------------------
| SANITIZE CONTENT
|--------------------------------------------------------------------------
*/
$content = sanitizeHtml($request->content);
/*
|--------------------------------------------------------------------------
| KIỂM TRA NỘI DUNG THỰC
|--------------------------------------------------------------------------
*/
//dd($content);
if (mb_strlen(trim(strip_tags($content))) < 20) {
return back()
->withInput()
->withErrors([
'content' => 'Nội dung phải có ít nhất 20 ký tự.',
]);
}
/*
|--------------------------------------------------------------------------
| Tạo Post
|--------------------------------------------------------------------------
*/
$post = Post::create([
'user_id' => auth()->id(),
'category_id' => $request->category_id,
'title' => $request->title,
'slug' => $request->slug,
'image' => $image,
'excerpt' => $request->excerpt,
// Lưu HTML đã sanitize
'content' => $content,
'status' => $request->status,
'published_at' => $publishedAt,
]);
/*
|--------------------------------------------------------------------------
| Lưu Tags
|--------------------------------------------------------------------------
*/
$post->tags()->sync(
$request->tags ?? []
);
/*
|--------------------------------------------------------------------------
| Redirect
|--------------------------------------------------------------------------
*/
return redirect()
->route('posts.show', $post)
->with(
'success',
'Bài viết đã được tạo thành công.'
);
}
//END store function
Create Blade Form:
<x-app-layout>
<div class="max-w-3xl mx-auto py-8">
<x-ui.card
title="Thêm Bài Viết"
description="Nhập thông tin bài viết">
<form
action="{{ route('posts.store') }}"
method="POST"
enctype="multipart/form-data">
@csrf
{{-- TITLE --}}
<x-ui.input
label="Tiêu đề"
name="title"
/>
{{-- SLUG --}}
<x-ui.input-slug
label="Slug"
name="slug"
source="title"
/>
{{-- EXCERPT --}}
<x-ui.textarea
label="Mô tả ngắn"
name="excerpt"
rows="3"
/>
{{-- CONTENT --}}
<x-ui.editor
label="Nội dung"
name="content"
height="500px"
/>
{{-- CATEGORY --}}
<x-ui.select
label="Danh mục"
name="category_id"
:options="$categories"
/>
{{-- TAGS --}}
<x-ui.select
label="Tags"
name="tags"
:options="$tags"
multiple
/>
{{-- IMAGE --}}
<x-ui.file-input
label="Ảnh đại diện"
name="image"
/>
{{-- STATUS --}}
<x-ui.select
label="Trạng thái"
name="status"
:options="[
'draft' => 'Nháp',
'published' => 'Xuất bản',
'hidden' => 'Ẩn',
'scheduled' => 'Lên lịch',
]"
/>
{{-- PUBLISHED AT --}}
<x-ui.input
label="Ngày xuất bản"
name="published_at"
type="datetime-local"
/>
<div class="mt-6">
<x-ui.button>
Lưu bài viết
</x-ui.button>
</div>
</form>
</x-ui.card>
<!--card chứa form-->
</div>
</x-app-layout>
abc
7. Validation(---)
$request->validate([
'title'=>'required|max:255',
'slug'=>'required|unique:posts',
'category_id'=>'required|exists:categories,id',
'image'=>'nullable|image|max:2048',
'content'=>'required'
]);
Laravel sẽ kiểm tra:
tiêu đề bắt buộc
slug không được trùng
category phải tồn tại
chỉ nhận ảnh
dung lượng tối đa
nội dung không được rỗng
8. Upload hình ảnh
Form
enctype="multipart/form-data"
Controller
$image = null;
if($request->hasFile('image')){
$image = $request->file('image')
->store('posts','public');
}
Kết quả
storage/app/public/posts/
abc123.jpg
xyz456.png
Laravel sẽ tự sinh tên file ngẫu nhiên để tránh trùng.
9. Lưu dữ liệu
Post::create([
'user_id'=>auth()->id(),
'category_id'=>$request->category_id,
'title'=>$request->title,
'slug'=>$request->slug,
'image'=>$image,
'excerpt'=>$request->excerpt,
'content'=>$request->content
]);
Tác giả sẽ được lấy từ
auth()->id()
Người dùng không thể giả mạo tác giả bài viết.
10. Hiển thị ảnh
Blade
<img src="{{ asset('storage/'.$post->image) }}">
Đừng quên tạo symbolic link
php artisan storage:link
Sau khi chạy lệnh này, Laravel sẽ tạo
public/storage
trỏ tới
storage/app/public
11. Cập nhật bài viết
Nếu người dùng chọn ảnh mới
Storage::disk('public')->delete($post->image);
sau đó upload lại.
Nếu không chọn ảnh
Laravel vẫn giữ ảnh cũ.
Đây là cách xử lý được sử dụng trong hầu hết các CMS hiện nay.
12. Xóa bài viết
Khi xóa
Storage::disk('public')->delete($post->image);
$post->delete();
Không nên để file ảnh bị "mồ côi" trong thư mục Storage.
13. Giao diện quản trị
Danh sách nên hiển thị
Thumbnail
Tiêu đề
Danh mục
Tác giả
Ngày tạo
Trạng thái
Nút Edit
Nút Delete
Có thể kết hợp:
Tailwind CSS
DataTables.net
Badge màu
Icon Heroicons
Xác nhận trước khi xóa
Đây cũng là giao diện mà chúng ta sẽ sử dụng xuyên suốt các module còn lại.
Nếu sau khi chỉnh sửa giao diện mà k thay đổi gì phải chạy npm run build
14. Kết quả đạt được
Sau bài học này, Blog CMS đã có module quản lý bài viết hoàn chỉnh.
✔ Thêm bài viết
✔ Sửa bài viết
✔ Xóa bài viết
✔ Upload ảnh
✔ Relationship User
✔ Relationship Category
✔ Validation
✔ Hiển thị ảnh
✔ Phân trang
✔ Quản lý tác giả
Đây là một module CRUD thực tế mà bạn sẽ gặp trong hầu hết các hệ thống quản trị nội dung sử dụng Laravel.
Tổng kết
Trong bài học này chúng ta đã kết hợp rất nhiều kiến thức đã học trước đó:
Resource Controller để xây dựng CRUD nhanh chóng.
Eloquent Relationship để liên kết Post với Category và User.
Validation để đảm bảo dữ liệu hợp lệ.
Storage để quản lý hình ảnh.
Authentication để tự động gán tác giả cho bài viết.
Pagination giúp danh sách bài viết dễ theo dõi khi số lượng tăng lên.
Đến thời điểm này, dự án Blog CMS đã gần hoàn chỉnh và có cấu trúc tương tự nhiều hệ thống quản trị nội dung được sử dụng trong thực tế.
Bài tiếp theo
Ở Bài 28 — CRUD Users, chúng ta sẽ xây dựng module quản lý người dùng dành cho quản trị viên:
Sau bài này, Blog CMS sẽ có đầy đủ ba module quản trị cốt lõi: Categories, Posts và Users.