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 columns.
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:
Sửa Category 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'
];
}
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:
php artisan route:list
| 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.
Edit chỉ khác Create ở 4 điểm:
-
action → categories.update
-
thêm
@method('PUT')
-
old(..., $category->...)
-
tiêu đề đổi thành "Sửa Category"
resources/views/categories/edit.blade.php
abc
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.
Phần mở rộng:(---)
Đây là cách mình thường làm cho dự án Laravel lớn: không dùng package, chỉ tạo các Blade Components để chuẩn hóa giao diện form.
Sau này tất cả CRUD chỉ việc gọi component.
Cấu trúc
resources/
└── views/
└── components/
└── ui/
├── input.blade.php
├── textarea.blade.php
├── select.blade.php
├── button.blade.php
└── card.blade.php
1. Card
resources/views/components/ui/card.blade.php
@props([
'title' => '',
'description' => ''
])
<div class="bg-white rounded-xl border border-gray-200 shadow-sm">
@if($title)
<div class="px-6 py-4 border-b">
<h2 class="text-xl font-semibold text-gray-800">
{{ $title }}
</h2>
@if($description)
<p class="text-sm text-gray-500 mt-1">
{{ $description }}
</p>
@endif
</div>
@endif
<div class="p-6">
{{ $slot }}
</div>
</div>
2. Input
resources/views/components/ui/input.blade.php
@props([
'label',
'name',
'type' => 'text'
])
<div class="mb-5">
<label
for="{{ $name }}"
class="block text-sm font-semibold text-gray-700 mb-2">
{{ $label }}
</label>
<input
id="{{ $name }}"
type="{{ $type }}"
name="{{ $name }}"
value="{{ old($name) }}"
{{ $attributes->merge([
'class' =>
'w-full rounded-lg border border-gray-300 bg-white
px-4 py-2.5
focus:border-blue-500
focus:ring-4
focus:ring-blue-100
outline-none
transition'
]) }}>
@error($name)
<p class="text-red-600 text-sm mt-2">
{{ $message }}
</p>
@enderror
</div>
3. Textarea
@props([
'label',
'name',
'rows' => 4
])
<div class="mb-5">
<label
class="block text-sm font-semibold text-gray-700 mb-2">
{{ $label }}
</label>
<textarea
name="{{ $name }}"
rows="{{ $rows }}"
{{ $attributes->merge([
'class' =>
'w-full rounded-lg border border-gray-300
px-4 py-2.5
focus:border-blue-500
focus:ring-4
focus:ring-blue-100'
]) }}>{{ old($name) }}</textarea>
@error($name)
<p class="text-red-600 text-sm mt-2">
{{ $message }}
</p>
@enderror
</div>
4. Button
@props([
'color' => 'blue'
])
<button
{{ $attributes->merge([
'class' =>
"px-6 py-2 rounded-lg
bg-$color-600
hover:bg-$color-700
text-white
transition"
]) }}>
{{ $slot }}
</button>
Lưu ý: Với Tailwind JIT, class động như bg-$color-600 sẽ không được build nếu không safelist. An toàn hơn là dùng @class hoặc match để ánh xạ màu.
5. Sử dụng
<x-app-layout>
<div class="max-w-3xl mx-auto py-8">
<x-ui.card
title="Thêm Category"
description="Nhập thông tin danh mục">
<form
action="{{ route('categories.store') }}"
method="POST">
@csrf
<x-ui.input
label="Tên Category"
name="name"/>
<x-ui.input
label="Slug"
name="slug"/>
<x-ui.textarea
label="Mô tả"
name="description"/>
<div class="flex justify-end gap-3">
<a
href="{{ route('categories.index') }}"
class="px-6 py-2 rounded-lg border hover:bg-gray-100">
Hủy
</a>
<x-ui.button>
Lưu
</x-ui.button>
</div>
</form>
</x-ui.card>
</div>
</x-app-layout>
Ưu điểm
✅ Toàn bộ CRUD có giao diện đồng nhất.
✅ Muốn đổi style chỉ sửa 1 component.
✅ Code create.blade.php và edit.blade.php ngắn, dễ đọc.
✅ Phù hợp để xây dựng khóa học Laravel dài vì tránh lặp lại hàng trăm dòng HTML.
Mình còn khuyên thêm một bước nữa: tạo bộ UI riêng kiểu AdminLTE nhưng viết bằng Tailwind (card, table, alert, modal, form, pagination, badge...).
Khi đó toàn bộ khóa học sẽ có một "design system" thống nhất thay vì chỉ có các component rời rạc. Đây là cách nhiều dự án Laravel chuyên nghiệp tổ chức giao diện quản trị.
Làm Flash Message:
Ý tưởng này rất đáng làm. Chỉ cần include một lần trong x-app-layout là toàn bộ CRUD sẽ có thông báo.
resources/views/components/alert.blade.php
@php
$alerts = [
'success' => [
'title' => 'Thành công',
'bg' => 'bg-green-50',
'border' => 'border-green-200',
'text' => 'text-green-800',
'icon' => '✓',
],
'error' => [
'title' => 'Lỗi',
'bg' => 'bg-red-50',
'border' => 'border-red-200',
'text' => 'text-red-800',
'icon' => '✕',
],
'warning' => [
'title' => 'Cảnh báo',
'bg' => 'bg-yellow-50',
'border' => 'border-yellow-200',
'text' => 'text-yellow-800',
'icon' => '⚠',
],
'info' => [
'title' => 'Thông tin',
'bg' => 'bg-blue-50',
'border' => 'border-blue-200',
'text' => 'text-blue-800',
'icon' => 'ⓘ',
],
];
@endphp
@foreach($alerts as $type => $alert)
@if(session($type))
<div
x-data="{ show: true }"
x-init="setTimeout(() => show = false, 5000)"
x-show="show"
x-transition
class="mb-6 rounded-lg border {{ $alert['border'] }} {{ $alert['bg'] }} p-4 shadow">
<div class="flex justify-between">
<div class="flex gap-3">
<div class="text-xl">
{{ $alert['icon'] }}
</div>
<div>
<div class="font-semibold {{ $alert['text'] }}">
{{ $alert['title'] }}
</div>
<div class="{{ $alert['text'] }}">
{{ session($type) }}
</div>
</div>
</div>
<button
@click="show = false"
class="{{ $alert['text'] }}">
✕
</button>
</div>
</div>
@endif
@endforeach
Sau đó chỉ cần đặt ở đầu nội dung của layout:
<x-app-layout>
<div class="py-8">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<x-alert />
{{ $slot }}
</div>
</div>
</x-app-layout>
Controller chỉ cần:
return redirect()
->route('categories.index')
->with('success', 'Thêm Category thành công!');
hoặc
->with('error', 'Không thể xóa Category.');
Mình còn đề xuất một bản "xịn" hơn
Thay vì chỉ có <x-alert />, hãy tạo luôn Flash Message tự động biến mất sau 4–5 giây bằng Alpine.js (đã có sẵn trong Breeze).
Khi đó thông báo sẽ giống AdminLTE, Filament hoặc Jetstream:
Đó là phiên bản mình sẽ chọn cho một khóa Laravel 12 vì nhìn hiện đại hơn hẳn mà gần như không tăng độ khó.
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ế.

x1