Từ bài này chúng ta sẽ bắt đầu xây dựng dự án Blog CMS hoàn chỉnh.
Chức năng đầu tiên là quản lý danh mục bài viết (Categories) với đầy đủ các thao tác thêm – sửa – xóa – tìm kiếm – phân trang.
Mục tiêu bài học
Tạo Resource Controller cho Category.
Xây dựng giao diện quản trị bằng Blade + Tailwind CSS.
Validate dữ liệu đầu vào.
Tìm kiếm theo tên danh mục.
Phân trang dữ liệu.
Hoàn thiện module CRUD chuẩn Laravel 12.
1. Tạo Model, Migration và Controller
Trường hợp 01: các bài trước đã tạo category rồi nhưng thiếu column
Tạo migration mới để thêm cột
Trong file migration:
Sửa CategoryFactory (nếu dùng factory)
Mở:
Nếu đang là:
đổi thành:
File seeder:
Sau đó
Trường hợp 2: nếu chưa có Category, tạo nhanh bằng Artisan:
Bash
php artisan make:model Category -mcr
Laravel sẽ tạo:
Migration
PHP
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->timestamps();
});
Chạy migration:
Bash
php artisan migrate
2. Khai báo Model
PHP
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Category extends Model
{
use HasFactory;
protected $fillable = [
'name',
'slug',
'description'
];
}
3. Định nghĩa Resource Route
Mở routes/web.php:
PHP
use App\Http\Controllers\CategoryController;
Route::middleware(['auth'])->group(function () {
Route::resource('categories', CategoryController::class);
});
Laravel tự tạo 7 route chuẩn:
| Method | URL |
|---|
| GET | /categories |
| GET | /categories/create |
| POST | /categories |
| GET | /categories/{"{category}"} |
| GET | /categories/{"{category}"}/edit |
| PUT | /categories/{"{category}"} |
| DELETE | /categories/{"{category}"} |
4. Hiển thị danh sách Categories (---)
Controller
PHP (pagination bằng Laravel)
use App\Models\Category;
use Illuminate\Http\Request;
public function index(Request $request)
{
$categories = Category::query()
->when($request->keyword, function ($query, $keyword) {
$query->where('name', 'like', "%{$keyword}%");
})
->latest()
->paginate(10)
->withQueryString();
return view('categories.index', compact('categories'));
}
View: resources/views/categories/index.blade.php
PHP
<x-app-layout>
<div class="max-w-7xl mx-auto p-6">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">Quản lý Categories</h1>
<a href="{{ route('categories.create') }}"
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700">
+ Thêm Category
</a>
</div>
<form method="GET" class="mb-4">
<input type="text"
name="keyword"
value="{{ request('keyword') }}"
placeholder="Tìm kiếm category..."
class="border rounded-lg px-4 py-2 w-80">
<button class="bg-gray-800 text-white px-4 py-2 rounded-lg">
Tìm
</button>
</form>
<div class="bg-white rounded-xl shadow overflow-hidden">
<table class="min-w-full">
<thead class="bg-gray-100">
<tr>
<th class="px-4 py-3 text-left">ID</th>
<th class="px-4 py-3 text-left">Tên</th>
<th class="px-4 py-3 text-left">Slug</th>
<th class="px-4 py-3 text-left">Ngày tạo</th>
<th class="px-4 py-3 text-right">Thao tác</th>
</tr>
</thead>
<tbody class="divide-y">
@forelse($categories as $category)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">{{ $category->id }}</td>
<td class="px-4 py-3 font-medium">
{{ $category->name }}
</td>
<td class="px-4 py-3 text-gray-600">
{{ $category->slug }}
</td>
<td class="px-4 py-3">
{{ $category->created_at->format('d/m/Y') }}
</td>
<td class="px-4 py-3 text-right space-x-2">
<a href="{{ route('categories.edit', $category) }}"
class="text-blue-600 hover:underline">
Sửa
</a>
<form action="{{ route('categories.destroy', $category) }}"
method="POST"
class="inline">
@csrf
@method('DELETE')
<button
onclick="return confirm('Xóa category này?')"
class="text-red-600 hover:underline">
Xóa
</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="text-center py-8 text-gray-500">
Chưa có dữ liệu
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-6">
{{ $categories->links() }}
</div>
</div>
</x-app-layout>
5. Tạo Category mới
Form Create
PHP
<form action="{{ route('categories.store') }}" method="POST">
@csrf
<div class="mb-4">
<label class="block mb-1">Tên Category</label>
<input type="text"
name="name"
value="{{ old('name') }}"
class="w-full border rounded-lg px-4 py-2">
@error('name')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<label class="block mb-1">Slug</label>
<input type="text"
name="slug"
value="{{ old('slug') }}"
class="w-full border rounded-lg px-4 py-2">
</div>
<div class="mb-4">
<label class="block mb-1">Mô tả</label>
<textarea name="description"
class="w-full border rounded-lg px-4 py-2"
rows="4">{{ old('description') }}</textarea>
</div>
<button class="bg-blue-600 text-white px-6 py-2 rounded-lg">
Lưu
</button>
</form>
Controller Store
PHP
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|min:2|max:100',
'slug' => 'required|unique:categories,slug',
'description' => 'nullable|max:1000',
]);
Category::create($validated);
return redirect()
->route('categories.index')
->with('success', 'Thêm category thành công!');
}
6. Cập nhật Category
Controller Edit
PHP
public function edit(Category $category)
{
return view('categories.edit', compact('category'));
}
Controller Update
PHP
public function update(Request $request, Category $category)
{
$validated = $request->validate([
'name' => 'required|min:2|max:100',
'slug' => 'required|unique:categories,slug,' . $category->id,
'description' => 'nullable|max:1000',
]);
$category->update($validated);
return redirect()
->route('categories.index')
->with('success', 'Cập nhật thành công!');
}
Điểm quan trọng là rule:
PHP
'slug' => 'required|unique:categories,slug,' . $category->id
Điều này cho phép giữ nguyên slug hiện tại mà không bị báo lỗi trùng.
7. Xóa Category
PHP
public function destroy(Category $category)
{
$category->delete();
return redirect()
->route('categories.index')
->with('success', 'Đã xóa category!');
}
Trong bài 30 chúng ta sẽ nâng cấp thành Soft Delete.
8. Tìm kiếm Categories
Chức năng tìm kiếm đã được tích hợp trong phương thức index():
PHP
->when($request->keyword, function ($query, $keyword) {
$query->where('name', 'like', "%{$keyword}%");
})
Ví dụ:
Nhập laravel → hiển thị các category chứa từ “laravel”.
Nhập php → hiển thị các category liên quan PHP.
Để trống → hiển thị toàn bộ dữ liệu.
Nhờ withQueryString(), khi chuyển trang Laravel vẫn giữ lại từ khóa tìm kiếm.
9. Phân trang
PHP
->paginate(10)
Hiển thị:
PHP
{{ $categories->links() }}
Laravel 12 mặc định sử dụng giao diện phân trang tương thích Tailwind CSS.
10. Hiển thị thông báo thành công
Thêm vào đầu trang:
PHP
@if(session('success'))
<div class="mb-4 rounded-lg bg-green-100 border border-green-300
text-green-800 px-4 py-3">
{{ session('success') }}
</div>
@endif
Kết quả:
11. Cấu trúc thư mục sau khi hoàn thành
app/
├── Http/
│ └── Controllers/
│ └── CategoryController.php
├── Models/
│ └── Category.php
resources/
└── views/
└── categories/
├── index.blade.php
├── create.blade.php
└── edit.blade.php
routes/
└── web.php
12. Kết quả đạt được
Sau bài học này, module Quản lý Categories của Blog CMS đã có đầy đủ:
CRUD
Quản lý dữ liệu
Danh sách Categories
Thêm mới Category
Cập nhật Category
Xóa Category
Validation
Kiểm tra dữ liệu
Search
Tìm kiếm
Pagination
Phân trang
Đây chính là mẫu CRUD chuẩn mà chúng ta sẽ tái sử dụng cho nhiều module khác trong dự án.
Bài tiếp theo
Sang Bài 27 — CRUD Posts, chúng ta sẽ kết hợp:
Relationship giữa Post và Category.
Gán bài viết cho User.
Upload hình ảnh.
Validate dữ liệu phức tạp hơn.
Hiển thị danh sách bài viết kèm tên danh mục và tác giả.
Từ bài 27 trở đi, dự án Blog CMS sẽ bắt đầu có cấu trúc giống một hệ thống quản trị nội dung thực tế.
