📘 Laravel 12 (2026) — BÀI 30 — SOFT DELETE
Ở các bài CRUD trước, khi gọi:$user->delete(); record sẽ bị xóa khỏi database.Ví dụ:users ──────────────────────── id | name 1 | Nguyễn Văn A 2 |...
Dành cho những ai chưa biết về lập trình hay các bạn muốn chuyển sang nghề thiết kế web
Dành cho những ai chưa biết về lập trình hay các bạn muốn chuyển sang nghề thiết kế web
Ở các bài CRUD trước, khi gọi:
$user->delete();
record sẽ bị xóa khỏi database.
Ví dụ:
users
────────────────────────
id | name
1 | Nguyễn Văn A
2 | Trần Văn B
3 | Lê Văn C
Xóa User id = 2:
$user->delete();
thì record có thể biến mất hoàn toàn:
users
────────────────────────
id | name
1 | Nguyễn Văn A
3 | Lê Văn C
Trong hệ thống thực tế, cách này khá nguy hiểm.
Ví dụ Admin vô tình xóa một bài viết quan trọng. Chúng ta sẽ muốn:
🗑️ Xóa
↓
📦 Đưa vào Thùng rác
↓
♻️ Có thể khôi phục
Đây chính là Soft Delete.
deleted_atSoftDeletes vào Modeldelete() lúc này hoạt động thế nào?withTrashed()onlyTrashed()forceDelete()delete() và forceDelete()forceDelete()Soft Delete là cơ chế đánh dấu một record đã bị xóa thay vì xóa thật khỏi database.
Laravel sử dụng cột:
deleted_at
Ví dụ:
posts
────────────────────────────────────
id | title | deleted_at
1 | Bài viết 1 | NULL
2 | Bài viết 2 | 2026-08-18 10:30:00
3 | Bài viết 3 | NULL
Trong đó:
deleted_at = NULL
→ record đang hoạt động.
deleted_at = thời gian
→ record đã bị Soft Delete.
Soft Delete đặc biệt hữu ích với:
Users
Posts
Categories
Products
Orders
Customers
Documents
Comments
Đặc biệt với Admin CMS:
Xóa
↓
Trash
↓
Restore
thường an toàn hơn xóa vĩnh viễn.
deleted_atTạo migration:
php artisan make:migration add_deleted_at_to_users_table
Mở migration:
Schema::table('users', function (Blueprint $table) {
$table->softDeletes();
});
Laravel sẽ tạo:
deleted_at
với kiểu dữ liệu phù hợp.
Sau đó:
php artisan migrate
Nếu muốn áp dụng cho Posts:
php artisan make:migration add_deleted_at_to_posts_table
Migration:
Schema::table('posts', function (Blueprint $table) {
$table->softDeletes();
});
Sau đó:
php artisan migrate
SoftDeletes vào ModelĐây là bước quan trọng.
Mở:
app/Models/User.php
Thêm:
use Illuminate\Database\Eloquent\SoftDeletes;
Sau đó:
class User extends Authenticatable
{
use SoftDeletes;
}
Model hoàn chỉnh có thể:
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
use HasFactory;
use SoftDeletes;
}
delete() lúc này hoạt động thế nào?Khi:
$user->delete();
Laravel không xóa record khỏi database.
Thay vào đó:
deleted_at = thời gian hiện tại
Ví dụ:
id | name | deleted_at
1 | Nguyễn Văn A | NULL
2 | Trần Văn B | 2026-08-18 11:00:00
User id = 2 vẫn tồn tại trong database.
Laravel tự động loại các record Soft Delete khỏi những truy vấn Eloquent thông thường.
Ví dụ:
$users = User::all();
sẽ chỉ lấy:
deleted_at IS NULL
Do đó User đã đưa vào Trash sẽ không xuất hiện trong danh sách bình thường.
Đây là một trong những ưu điểm lớn của Soft Delete.
withTrashed()Nếu muốn lấy cả record đang hoạt động và đã xóa:
$users = User::withTrashed()->get();
//hoặc
$users = User::query()
// SOFT DELETE
->withTrashed()
Kết quả:
User bình thường
+
User đã Soft Delete
onlyTrashed()Nếu chỉ muốn lấy những record đã xóa:
$users = User::onlyTrashed()->get();
Đây chính là dữ liệu cho trang:
🗑️ Trash
Ví dụ:
public function trash()
{
$users = User::onlyTrashed()->latest('deleted_at')->paginate(10);
return view('users.trash', compact('users'));
}
Muốn khôi phục User:
$user->restore();
Laravel sẽ đưa:
deleted_at
về:
NULL
Ví dụ:
Trước:
id | name | deleted_at
2 | ABC | 2026-08-18
Sau restore:
id | name | deleted_at
2 | ABC | NULL
User xuất hiện trở lại trong danh sách bình thường.
Trong:
routes/web.php
thêm:
Route::patch(
'users/{user}/restore',
[UserController::class, 'restore']
)->name('users.restore');
Trong UserController:
public function restore($id)
{
$user = User::withTrashed()->findOrFail($id);
$user->restore();
return redirect()
->route('admin.users.index')
->with('success', 'Khôi phục User thành công.');
}
Chú ý:
withTrashed()
rất quan trọng.
Nếu không có nó, User đã Soft Delete sẽ không được tìm thấy bởi truy vấn Eloquent thông thường.
Blade:
{{-- KHÔI PHỤC --}}
@if ($user->trashed())
<form
action="{{ route('admin.users.restore', $user->id) }}"
method="POST"
class="inline">
@csrf
@method('PATCH')
<button
type="submit"
class="px-3 py-1 bg-green-600 text-white rounded hover:bg-green-700">
♻️ Khôi phục
</button>
</form>
@endif
forceDelete()Soft Delete không xóa thật.
Nếu muốn xóa vĩnh viễn:
$user->forceDelete();
Record sẽ thực sự bị xóa khỏi database.
Ví dụ:
Soft Delete:
id | name | deleted_at
2 | ABC | 2026-08-18
Sau:
$user->forceDelete();
record id = 2 không còn tồn tại.
delete() và forceDelete()| Method | Kết quả |
|---|---|
delete() | Soft Delete |
restore() | Khôi phục |
forceDelete() | Xóa vĩnh viễn |
withTrashed() | Lấy cả record đã xóa |
onlyTrashed() | Chỉ lấy record đã xóa |
Đây là 5 method cần nhớ.
Chú ý: khi softdelete() thì posts của user đó vẫn còn -> cần sửa post model
Muốn lấy được cả User đã bị xóa:
public function user()
{
return $this->belongsTo(User::class)
->withTrashed();
}
Route (phải đặt trước restore và resource):
Route::get(
'users/trash',
[UserController::class, 'trash']
)->name('users.trash');
Controller:
public function trash()
{
$users = User::onlyTrashed()
->latest('deleted_at')
->paginate(10);
return view('admin.users.trash', compact('users'));
}
Ví dụ:
<table class="w-full">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Email</th>
<th>Deleted At</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@foreach ($users as $user)
<tr>
<td>{{ $user->id }}</td>
<td>{{ $user->name }}</td>
<td>{{ $user->email }}</td>
<td>
{{ $user->deleted_at }}
</td>
<td>
<form
method="POST"
action="{{ route('admin.users.restore', $user->id) }}"
>
@csrf
@method('PATCH')
<button type="submit">
♻️ Restore
</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
{{ $users->links() }}
Trong Trash:
<form
method="POST"
action="{{ route('admin.users.forceDelete', $user->id) }}"
>
@csrf
@method('DELETE')
<button type="submit">
🗑️ Xóa vĩnh viễn
</button>
</form>
Route:
Route::delete(
'users/{user}/force-delete',
[UserController::class, 'forceDelete']
)->name('users.forceDelete');
forceDelete()public function forceDelete($id)
{
$user = User::onlyTrashed()->findOrFail($id);
$user->forceDelete();
return redirect()
->route('admin.users.trash')
->with('success', 'Đã xóa vĩnh viễn.');
}
Ở đây sử dụng:
onlyTrashed()
để đảm bảo chỉ xóa vĩnh viễn những record đang nằm trong Trash.
Có thể xóa nhiều record:
User::onlyTrashed()->forceDelete();
Hoặc:
User::onlyTrashed()->each(function ($user) {
$user->forceDelete();
});
Tuy nhiên với dữ liệu lớn, cần cân nhắc cách xử lý để tránh tải quá nhiều model vào memory.
UserController:
public function forceDeleteAll()
{
User::onlyTrashed()->forceDelete();
return redirect()
->route('admin.users.trash')
->with('success', 'Đã xóa vĩnh viễn tất cả thùng rác.');
}
Route:
Route::delete(
'users/force-delete-all',
[UserController::class, 'forceDeleteAll']
)->name('users.forceDeleteAll');
Phần trash.blade.php xem phần 21
Có thể khôi phục nhiều record:
User::onlyTrashed()
->whereIn('id', $ids)
->restore();
Ví dụ:
ids = [5, 8, 12]
sẽ khôi phục ba User.
UserController
public function restoreMany(Request $request)
{
$ids = $request->input('ids', []);
User::onlyTrashed()
->whereIn('id', $ids)
->restore();
return redirect()
->route('admin.users.trash')
->with('success', 'Khôi phục User thành công.');
}
Route
Route::patch(
'users/restore-many',
[UserController::class, 'restoreMany']
)->name('users.restoreMany');
<x-app-layout>
<div class="max-w-7xl mx-auto py-8">
<x-ui.card
title="Thùng rác Users"
description="Quản lý các User đã bị Soft Delete">
{{-- ACTION --}}
<div class="flex justify-between items-center mb-4">
{{-- RESTORE MANY --}}
<form
method="POST"
action="{{ route('admin.users.restoreMany') }}"
id="restoreForm">
@csrf
@method('PATCH')
<button
type="submit"
onclick="return confirm('Bạn có chắc muốn khôi phục các User đã chọn?')"
class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
♻️ Khôi phục đã chọn
</button>
</form>
{{-- DELETE ALL --}}
<form
method="POST"
action="{{ route('admin.users.forceDeleteAll') }}"
onsubmit="return confirm('⚠️ Bạn có chắc muốn XÓA VĨNH VIỄN toàn bộ User trong thùng rác? Hành động này không thể hoàn tác!')">
@csrf
@method('DELETE')
<button
type="submit"
class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700">
🗑️ Xóa vĩnh viễn tất cả
</button>
</form>
</div>
{{-- TABLE --}}
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b">
{{-- SELECT ALL --}}
<th class="p-3 text-left">
<input
type="checkbox"
id="selectAll"
class="w-4 h-4">
</th>
<th class="p-3 text-left">
ID
</th>
<th class="p-3 text-left">
Name
</th>
<th class="p-3 text-left">
Email
</th>
<th class="p-3 text-left">
Deleted At
</th>
<th class="p-3 text-left">
Action
</th>
</tr>
</thead>
<tbody>
@forelse ($users as $user)
<tr class="border-b">
{{-- CHECKBOX --}}
<td class="p-3">
<input
type="checkbox"
name="ids[]"
value="{{ $user->id }}"
form="restoreForm"
class="user-checkbox w-4 h-4">
</td>
{{-- ID --}}
<td class="p-3">
{{ $user->id }}
</td>
{{-- NAME --}}
<td class="p-3">
{{ $user->name }}
</td>
{{-- EMAIL --}}
<td class="p-3">
{{ $user->email }}
</td>
{{-- DELETED AT --}}
<td class="p-3">
{{ $user->deleted_at }}
</td>
{{-- ACTION --}}
<td class="p-3">
<div class="flex gap-2">
{{-- RESTORE --}}
<form
method="POST"
action="{{ route('admin.users.restore', $user->id) }}">
@csrf
@method('PATCH')
<button
type="submit"
class="px-3 py-1 bg-green-600 text-white rounded hover:bg-green-700">
♻️ Restore
</button>
</form>
{{-- FORCE DELETE --}}
<form
method="POST"
action="{{ route('admin.users.forceDelete', $user->id) }}"
onsubmit="return confirm('⚠️ Xóa vĩnh viễn User này? Không thể hoàn tác!')">
@csrf
@method('DELETE')
<button
type="submit"
class="px-3 py-1 bg-red-600 text-white rounded hover:bg-red-700">
🗑️ Xóa vĩnh viễn
</button>
</form>
</div>
</td>
</tr>
@empty
<tr>
<td
colspan="6"
class="p-6 text-center text-gray-500">
🗑️ Thùng rác đang trống.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
{{-- PAGINATION --}}
<div class="mt-4">
{{ $users->links() }}
</div>
</x.ui.card>
</div>
{{-- SELECT ALL --}}
<script>
const selectAll = document.getElementById('selectAll');
const checkboxes = document.querySelectorAll('.user-checkbox');
selectAll?.addEventListener('change', function () {
checkboxes.forEach(checkbox => {
checkbox.checked = this.checked;
});
});
</script>
</x-app-layout>
Trang Users có thể thêm:
<a href="{{ route('admin.users.trash') }}">
🗑️ Thùng rác
</a>
Giao diện:
Users
[+ Thêm User] [🗑️ Thùng rác]
-------------------------------------
| ID | Name | Email | Role | Action |
-------------------------------------
USERS
│
│ delete()
↓
Soft Delete
│
↓
deleted_at
│
↓
TRASH
↙ ↘
restore() forceDelete()
↓ ↓
Users Xóa thật
Posts cũng tương tự.
Bước 1: thêm nút restore trong view (xem lại phần 13)
Bước 2:
Model:
use Illuminate\Database\Eloquent\SoftDeletes;
class Post extends Model
{
use SoftDeletes;
}
Migration:
$table->softDeletes();
Xóa:
$post->delete();
Trash:
Post::onlyTrashed()->get();
Restore:
$post->restore();
Xóa vĩnh viễn:
$post->forceDelete();
Ví dụ User có Posts:
$user->posts
Các Post đã Soft Delete thông thường sẽ không xuất hiện.
Nếu muốn lấy cả Posts đã xóa:
$user->posts()->withTrashed()->get();
Chỉ lấy Posts đã xóa:
$user->posts()->onlyTrashed()->get();
Điều này rất quan trọng khi xây dựng hệ thống CMS có nhiều relationship.
Có thể sử dụng:
$user->trashed();
Nếu:
true
→ User đang bị Soft Delete.
Nếu:
false
→ User đang hoạt động.
Ví dụ:
if ($user->trashed()) {
// User đang nằm trong Trash
}
Đây là điểm rất quan trọng.
Soft Delete:
Database
↓
deleted_at
không phải:
Backup Database
Nếu database bị:
mất dữ liệu
hỏng ổ đĩa
DROP TABLE
server lỗi
thì Soft Delete không thể bảo vệ dữ liệu.
Vì vậy dự án thực tế vẫn cần:
Database Backup
+
Soft Delete
Không phải bảng nào cũng cần.
Ví dụ một số dữ liệu tạm thời hoặc dữ liệu không cần khôi phục có thể xóa trực tiếp.
Soft Delete cũng làm database giữ lại record đã xóa, vì vậy bảng có lượng dữ liệu rất lớn cần có chiến lược dọn dẹp phù hợp.
Đối với Blog CMS trong khóa học này, có thể áp dụng:
Users
↓
Soft Delete
Posts
↓
Soft Delete
Categories
↓
Soft Delete
Giao diện Admin:
Users
├── Active
└── Trash
Posts
├── Published
├── Draft
└── Trash
Categories
├── Active
└── Trash
Sau bài này, chúng ta đã biết:
Soft Delete
↓
deleted_at
Các method quan trọng:
delete()
→ đưa vào Trash.
restore()
→ khôi phục.
forceDelete()
→ xóa vĩnh viễn.
withTrashed()
→ lấy cả record đã xóa.
onlyTrashed()
→ chỉ lấy record đã xóa.
trashed()
→ kiểm tra record có đang bị Soft Delete hay không.
Mô hình hoàn chỉnh:
CRUD
│
↓
Users
│
┌────┴────┐
↓ ↓
Active Trash
│
┌────┴────┐
↓ ↓
Restore Force Delete
│
↓
Active
Thêm Soft Delete cho users.
Thêm Soft Delete cho posts.
Thêm nút 🗑️ Thùng rác.
Hiển thị danh sách record đã xóa.
Thêm ♻️ Restore.
Thêm 🗑️ Xóa vĩnh viễn.
Thêm xác nhận trước khi forceDelete().
Thêm Pagination cho Trash.
Thử kết hợp Trash với DataTables ở Bài 29.
Bài tiếp theo — Bài 31: Accessor & Mutator sẽ tìm hiểu cách Laravel biến đổi dữ liệu khi đọc/ghi Model, ví dụ định dạng tên, tạo thuộc tính ảo và tự động xử lý dữ liệu trước khi lưu vào database.
Ở các bài CRUD trước, khi gọi:$user->delete(); record sẽ bị xóa khỏi database.Ví dụ:users ──────────────────────── id | name 1 | Nguyễn Văn A 2 |...
Trong các bài trước, chúng ta đã xây dựng được các chức năng CRUD cho:
Categories
Posts
Users
Tuy nhiên, khi dữ liệu bắt đầu nhiều lên, một bảng kiểu:
User::all();sẽ nhanh chóng trở nên bất tiện.
Ví dụ hệ thống có 10.000 User:
Không thể hiển thị tất cả trên một trang.
Người quản trị cần tìm User theo tên/email.
Cần sắp xếp theo tên, ngày đăng ký...
Cần lọc theo Role.
Cần phân trang.
Và nếu dữ liệu lớn, nên để Database xử lý Search / Sort / Filter / Pagination thay vì tải toàn bộ dữ liệu về trình duyệt.
Trong Laravel 13, Query Builder/Eloquent hỗ trợ trực tiếp các cơ chế phân trang như paginate(), simplePaginate() và cursorPaginate(). (Laravel API)
Sau bài này, chúng ta sẽ xây dựng trang:
Dashboard
└── Users
├── 🔎 Search
├── 🎯 Filter Role
├── ↕ Sort
├── 📄 Pagination
└── 📊 DataTablesNgười học sẽ hiểu được hai cách:
Browser
↓
Request
↓
Laravel
↓
MySQL
↓
paginate()
↓
BladeBrowser
↓
DataTables
↓ AJAX
Laravel
↓
MySQL
↓
JSON
↓
DataTablesVới dữ liệu rất lớn, DataTables có cơ chế server-side processing, tức là việc tìm kiếm, sắp xếp và phân trang được thực hiện phía server/database thay vì tải toàn bộ dữ liệu về browser. (DataTables Plus)
Trong dự án Laravel 13 của chúng ta đã có bảng:
usersVí dụ:
| id | name | role | |
|---|---|---|---|
| 1 | Nguyễn Văn A | a@gmail.com | admin |
| 2 | Trần Văn B | b@gmail.com | user |
| 3 | Lê Văn C | c@gmail.com | editor |
Model:
app/Models/User.phpVí dụ:
class User extends Authenticatable
{
protected $fillable = [
'name',
'email',
'password',
'role',
];
}Nếu chưa có:
php artisan make:controller UserController --resourceLaravel sẽ tạo:
app/Http/Controllers/UserController.phpController có các method:
index()
create()
store()
show()
edit()
update()
destroy()Trong bài này chúng ta tập trung vào:
index()
p/S: vì bài trước đã xong phần này nên rename file cũ và tạo lại file index.blade.php
Mở:
routes/web.phpThêm:
use App\Http\Controllers\UserController;
Route::resource('users', UserController::class);Sau đó:
php artisan route:list
//php artisan route:list --path=admin
Chúng ta sẽ có:
GET /users
POST /users
GET /users/create
GET /users/{user}
GET /users/{user}/edit
PUT /users/{user}
DELETE /users/{user}Trang danh sách User:
/userssẽ chạy:
UserController@indexĐầu tiên thử cách đơn giản nhất.
Trong:
UserController.phpviết:
use App\Models\User;
public function index()
{
$users = User::latest()->paginate(10);
return view('admin.users.index', compact('users'));
}Ý nghĩa:
paginate(10)là:
Mỗi trang tối đa 10 User.
Ví dụ có 53 User:
Trang 1 → 10 User
Trang 2 → 10 User
Trang 3 → 10 User
Trang 4 → 10 User
Trang 5 → 10 User
Trang 6 → 3 UserLaravel cung cấp paginate() để tạo paginator đầy đủ thông tin tổng số bản ghi; ngoài ra còn có simplePaginate() và cursorPaginate() cho những trường hợp phù hợp khác. (Laravel API)
Trong:
resources/views/users/index.blade.phpsau bảng:
<div class="mt-4">
{{ $users->links() }}
</div>Ví dụ:
<table class="w-full">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Email</th>
<th>Role</th>
</tr>
</thead>
<tbody>
@foreach ($users as $user)
<tr>
<td>{{ $user->id }}</td>
<td>{{ $user->name }}</td>
<td>{{ $user->email }}</td>
<td>{{ $user->role }}</td>
</tr>
@endforeach
</tbody>
</table>
<div class="mt-4">
{{ $users->links() }}
</div>Bây giờ chúng ta thêm ô tìm kiếm.
Giao diện:
┌─────────────────────────────────────────────┐
│ 🔎 Tìm User... [Search] │
└─────────────────────────────────────────────┘Blade:
<form method="GET" action="{{ route('admin.users.index') }}">
<input
type="text"
name="search"
value="{{ request('search') }}"
placeholder="Tìm tên hoặc email..."
>
<button type="submit">
Tìm kiếm
</button>
</form>Khi người dùng nhập:
nguyenURL sẽ trở thành:
/users?search=nguyenSửa:
public function index()
{
$users = User::latest()->paginate(10);
return view('users.index', compact('users'));
}thành:
public function index()
{
$query = User::query();
if (request('search')) {
$search = request('search');
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
$users = $query
->latest()
->paginate(10)
->withQueryString();
return view('admin.users.index', compact('users'));
}Đây là một pattern cực kỳ quan trọng:
$query = User::query();Sau đó xây dựng query từng bước.
withQueryString()?Nếu tìm:
nguyentrang đầu tiên có URL:
/users?search=nguyenKhi chuyển sang trang 2, chúng ta muốn:
/users?page=2&search=nguyenchứ không phải:
/users?page=2Vì vậy:
->withQueryString()giúp giữ lại các query parameter hiện tại.
Chúng ta có thể tìm:
name
emailbằng:
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});SQL tương đương về ý tưởng:
WHERE
name LIKE '%nguyen%'
OR email LIKE '%nguyen%'Đây là lý do phải sử dụng:
where(function () {})để nhóm điều kiện OR lại với nhau.
Bây giờ thêm bộ lọc:
Role:
[ Tất cả ▼ ]
[ admin ]
[ editor ]
[ user ]Blade:
{{-- Lọc theo role --}}
<form action="{{ route('admin.users.index') }}" method="get">
<select name="role">
<option value="">Tất cả Role</option>
<option value="admin"
@selected(request('role') === 'admin')>
Admin
</option>
<option value="editor"
@selected(request('role') === 'editor')>
Editor
</option>
<option value="user"
@selected(request('role') === 'user')>
User
</option>
</select>
<button type="submit">
Lọc
</button>
</form>
Trong Controller:
public function index()
{
$query = User::query();
// SEARCH
if ($search = request('search')) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
// FILTER ROLE
if ($role = request('role')) {
$query->where('role', $role);
}
// PAGINATION
$users = $query
->latest()
->paginate(10)
->withQueryString();
return view('users.index', compact('users'));
}Bây giờ có thể:
Search:
nguyen
Role:
adminURL:
/users?search=nguyen&role=adminDatabase sẽ tìm:
name/email chứa "nguyen"
AND
role = admin
Bây giờ chúng ta muốn:
Sắp xếp:
Mới nhất
Cũ nhất
Tên A → Z
Tên Z → ABlade:
<thead>
<tr>
<th>ID</th>
<th>
@if (request('sort') === 'name_asc')
<a href="{{ request()->fullUrlWithQuery(['sort' => 'name_desc']) }}">
Name ↓
</a>
@else
<a href="{{ request()->fullUrlWithQuery(['sort' => 'name_asc']) }}">
Name ↑
</a>
@endif
</th>
<th>Email</th>
<th>
@if (request('sort') === 'role_asc')
<a href="{{ request()->fullUrlWithQuery(['sort' => 'role_desc']) }}">
Role ↓
</a>
@else
<a href="{{ request()->fullUrlWithQuery(['sort' => 'role_asc']) }}">
Role ↑
</a>
@endif
</th>
</tr>
</thead>
Controller:
//SORT
switch (request('sort')) {
case 'name_asc':
$query->orderBy('name', 'asc');
break;
case 'name_desc':
$query->orderBy('name', 'desc');
break;
case 'role_asc':
$query->orderBy('role', 'asc');
break;
case 'role_desc':
$query->orderBy('role', 'desc');
break;
default:
$query->latest();
break;
}
Sau đó:
$users = $query
->paginate(10)
->withQueryString();
Đến đây chúng ta có:
Search
Filter
Sort
Pagination
Code hoàn chỉnh:
public function index()
{
$query = User::query();
/*
|--------------------------------------------------------------------------
| SEARCH
|--------------------------------------------------------------------------
*/
if ($search = request('search')) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
/*
|--------------------------------------------------------------------------
| FILTER ROLE
|--------------------------------------------------------------------------
*/
if ($role = request('role')) {
$query->where('role', $role);
}
/*
|--------------------------------------------------------------------------
| SORT
|--------------------------------------------------------------------------
*/
switch (request('sort')) {
case 'name_asc':
$query->orderBy('name', 'asc');
break;
case 'name_desc':
$query->orderBy('name', 'desc');
break;
case 'role_asc':
$query->orderBy('role', 'asc');
break;
case 'role_desc':
$query->orderBy('role', 'desc');
break;
default:
$query->latest();
break;
}
/*
|--------------------------------------------------------------------------
| PAGINATION
|--------------------------------------------------------------------------
*/
$users = $query
->paginate(10)
->withQueryString();
return view('users.index', compact('users'));
}Đây chính là cấu trúc mà chúng ta sẽ thường xuyên sử dụng trong các trang quản trị Laravel.
Blade:
<form
method="GET"
action="{{ route('users.index') }}"
class="flex gap-2 mb-4"
>
{{-- SEARCH --}}
<input
type="text"
name="search"
value="{{ request('search') }}"
placeholder="Tìm tên hoặc email..."
>
{{-- ROLE --}}
<select name="role">
<option value="">
Tất cả Role
</option>
<option
value="admin"
@selected(request('role') === 'admin')
>
Admin
</option>
<option
value="editor"
@selected(request('role') === 'editor')
>
Editor
</option>
<option
value="user"
@selected(request('role') === 'user')
>
User
</option>
</select>
{{-- SORT --}}
<select name="sort">
<option
value="latest"
@selected(request('sort', 'latest') === 'latest')
>
Mới nhất
</option>
<option
value="oldest"
@selected(request('sort') === 'oldest')
>
Cũ nhất
</option>
<option
value="name_asc"
@selected(request('sort') === 'name_asc')
>
Tên A → Z
</option>
<option
value="name_desc"
@selected(request('sort') === 'name_desc')
>
Tên Z → A
</option>
</select>
<button type="submit">
🔎 Tìm kiếm
</button>
</form>Kết quả:
┌──────────────────────────────────────────────────────────┐
│ 🔎 Nguyễn [Admin ▼] [Mới nhất ▼] [ Tìm kiếm ] │
└──────────────────────────────────────────────────────────┘
Nên có thêm:
<a href="{{ route('admin.users.index') }}">
Xóa bộ lọc
</a>Khi click:
/admin/userstoàn bộ:
search
role
sortđược xóa.
Nếu dự án đang sử dụng Tailwind:
{{ $users->links() }}Laravel có thể render pagination view tương ứng với frontend stack đã cấu hình.
Nếu cần tùy chỉnh giao diện, Laravel cho phép publish pagination views để chỉnh sửa.
Cách trên hoàn toàn đủ cho nhiều dự án:
Search
+
Filter
+
Sort
+
PaginationNhưng khi làm Admin Dashboard, người dùng thường muốn giao diện giống:
┌───────────────────────────────────────────────────────────┐
│ Users [+ Add User] │
├───────────────────────────────────────────────────────────┤
│ Search [____________] Role [____] │
├────┬──────────────┬────────────────┬─────────┬───────────┤
│ ID │ Name │ Email │ Role │ Action │
├────┼──────────────┼────────────────┼─────────┼───────────┤
│ 1 │ Nguyễn Văn A │ a@gmail.com │ Admin │ Edit │
│ 2 │ Trần Văn B │ b@gmail.com │ User │ Edit │
│ 3 │ Lê Văn C │ c@gmail.com │ Editor │ Edit │
└────┴──────────────┴────────────────┴─────────┴───────────┘
Showing 1 to 10 of 245 users
1 2 3 4 5 NextĐây là lúc DataTables trở nên hữu ích.
DataTables.net là thư viện JavaScript dùng để biến HTML <table> thành một bảng dữ liệu tương tác.
Nó cung cấp nhiều chức năng:
Search
Sort
Pagination
Ajax
Responsive
Column ordering
Filtering
Server-side processingĐặc biệt:
serverSide: truecho phép DataTables gửi yêu cầu lên server mỗi khi người dùng:
chuyển trang
tìm kiếm
sort
filter
DataTables sẽ gửi request tới server và server trả JSON về cho bảng. (DataTables Plus)
Có hai cách sử dụng.
MySQL
↓
Laravel
↓
10.000 records
↓
Browser
↓
DataTablesBrowser nhận toàn bộ:
10.000 recordssau đó DataTables xử lý.
Phù hợp với:
100
500
1.000bản ghi tùy trường hợp.
Browser
↓
DataTables
↓
Laravel
↓
MySQLBrowser chỉ nhận dữ liệu cần thiết cho trang hiện tại.
Ví dụ:
Database:
1.000.000 UsersNhưng trang hiện tại chỉ cần:
10 Usersthì server xử lý:
Search
Sort
Filter
Paginationrồi trả về 10 records.
Đây là cách phù hợp hơn với dữ liệu rất lớn. DataTables cũng mô tả server-side processing là cách để database thực hiện phần tìm kiếm, ordering và paging cho các tập dữ liệu lớn. (DataTables Plus)
Có thể sử dụng CDN.
Trong layout:
<link
rel="stylesheet"
href="https://cdn.datatables.net/2.3.4/css/dataTables.dataTables.min.css"
>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.datatables.net/2.3.4/js/dataTables.min.js"></script>Lưu ý: Khi triển khai dự án thực tế, nên kiểm tra phiên bản DataTables hiện tại và cố định version phù hợp thay vì phụ thuộc vào một URL không version.
Ghi chú: xem thêm mục 4 bài 27 CRUD Posts để biết cách cài Datatables trong x-app-layout.
Blade:
<table id="users-table" class="display w-full">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Created</th>
</tr>
</thead>
<tbody>
</tbody>
</table>Chúng ta không cần:
@foreachvì dữ liệu sẽ được DataTables lấy bằng AJAX.
JavaScript:
$(document).ready(function () {
$('#users-table').DataTable({
ajax: '{{ route('admin.users.index') }}',
columns: [
{
data: 'id'
},
{
data: 'name'
},
{
data: 'email'
},
{
data: 'role'
},
{
data: 'created_at',
render: function (data) {
if (!data) {
return '';
}
const date = new Date(data);
return date.toLocaleString('vi-VN', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
}
]
});
});DataTables sẽ gửi request:
/admin/usersvà mong server trả JSON.
Trong Controller:
public function index(Request $request)
{
if ($request->ajax()) {
$users = User::latest()->get();
return response()->json([
'data' => $users
]);
}
return view('admin.users.index');
}Khi AJAX request:
/admin/usersLaravel trả:
{
"data": [
{
"id": 1,
"name": "Nguyễn Văn A",
"email": "a@gmail.com",
"role": "admin"
},
{
"id": 2,
"name": "Trần Văn B",
"email": "b@gmail.com",
"role": "user"
}
]
}DataTables đọc:
data: 'id'
data: 'name'
data: 'email'và đưa dữ liệu vào bảng.
Đoạn này:
User::latest()->get();sẽ lấy toàn bộ User.
Nếu database có:
500.000 Usersthì:
500.000 Users
↓
Laravel
↓
JSON
↓
Browserlà điều không nên làm.
Đây chính là lúc chúng ta cần:
Server-side DataTables
JavaScript:
$('#users-table').DataTable({
processing: true,
serverSide: true,
ajax: '{{ route('admin.users.index') }}',
columns: [
{
data: 'id'
},
{
data: 'name'
},
{
data: 'email'
},
{
data: 'role'
},
{
data: 'created_at'
}
]
});Hai dòng quan trọng:
processing: true,
serverSide: true,DataTables sẽ gửi các thông tin cần thiết cho server để server thực hiện processing. (DataTables Plus)
Ví dụ người dùng đang ở:
Trang 3DataTables có thể gửi các thông tin liên quan đến:
start
length
search
order
columns
drawVí dụ ý tưởng:
start = 20
length = 10
search = nguyenCó nghĩa:
Bắt đầu từ record 20
Lấy 10 record
Search "nguyen"Server xử lý:
MySQL
↓
WHERE
ORDER BY
LIMIT
OFFSETrồi trả kết quả.
Ví dụ đơn giản:
public function index(Request $request)
{
if ($request->ajax()) {
$query = User::query();
/*
|--------------------------------------------------------------------------
| SEARCH
|--------------------------------------------------------------------------
*/
if ($request->search['value']) {
$search = $request->search['value'];
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
/*
|--------------------------------------------------------------------------
| TOTAL
|--------------------------------------------------------------------------
*/
$recordsTotal = User::count();
$recordsFiltered = $query->count();
/*
|--------------------------------------------------------------------------
| ORDER
|--------------------------------------------------------------------------
*/
$query->orderBy('id', 'desc');
/*
|--------------------------------------------------------------------------
| PAGINATION
|--------------------------------------------------------------------------
*/
$users = $query
->skip($request->start)
->take($request->length)
->get();
/*
|--------------------------------------------------------------------------
| RESPONSE
|--------------------------------------------------------------------------
*/
return response()->json([
'draw' => intval($request->draw),
'recordsTotal' => $recordsTotal,
'recordsFiltered' => $recordsFiltered,
'data' => $users,
]);
}
return view('admin.users.index');
}Đây là phiên bản tự xử lý protocol của DataTables, rất tốt để người học hiểu bản chất.
DataTables cần các thông tin chính:
{
"draw": 1,
"recordsTotal": 500,
"recordsFiltered": 25,
"data": []
}Ý nghĩa:
drawĐánh dấu request hiện tại.
recordsTotalTổng số User trước khi filter:
500recordsFilteredTổng số User sau filter:
25dataDữ liệu của trang hiện tại:
10 Users
Bây giờ người dùng click:
Name ↕DataTables gửi thông tin column/order.
Ví dụ:
order[0][column] = 1
order[0][dir] = ascÝ nghĩa:
Column 1
↓
ASCChúng ta có thể xử lý (chèn chỗ ORDER - nhớ xóa dòng cũ):
$columns = [
'id',
'name',
'email',
'role',
'created_at',
];
$orderColumn = $columns[$request->order[0]['column']] ?? 'id';
$orderDirection = $request->order[0]['dir'] ?? 'desc';
$query->orderBy(
$orderColumn,
$orderDirection
);
Không nên viết:
$query->orderBy(
$request->order[0]['column'],
$request->order[0]['dir']
);vì giá trị từ browser là input không đáng tin cậy.
Thay vào đó:
$columns = [
'id',
'name',
'email',
'role',
'created_at',
];sau đó map:
$orderColumn = $columns[$index] ?? 'id';Đây là một nguyên tắc rất quan trọng:
Không đưa trực tiếp tên column từ request của người dùng vào câu SQL nếu không kiểm soát whitelist.
Đến đây DataTables đã có:
┌──────────────┐
│ DataTables │
└──────┬───────┘
│
┌─────────┼─────────┐
↓ ↓ ↓
Search Sort Paging
│ │ │
└─────────┼─────────┘
↓
Laravel
↓
MySQLĐây là kiến trúc rất phổ biến trong Admin Dashboard.
Dùng:
paginate()khi:
Website đơn giản
Blog
Danh sách bài viết
Danh mục
Trang frontend
Không cần Ajax
Muốn SEO tốt
Muốn code đơn giản
Ví dụ:
$posts = Post::latest()->paginate(12);
DataTables rất phù hợp với:
Admin DashboardVí dụ:
Users
Posts
Orders
Products
Invoices
Customers
Transactions
LogsĐặc biệt khi cần:
Search
Sort
Filter
Pagination
Ajaxtrong cùng một bảng.
Khi dữ liệu lớn.
Ví dụ:
1.000 recordsCó thể client-side vẫn ổn tùy trường hợp.
Nhưng:
100.000
500.000
1.000.000+thì server-side processing thường hợp lý hơn.
DataTables cũng khuyến nghị server-side processing cho các tập dữ liệu rất lớn, vì database có thể thực hiện các tác vụ filtering, ordering và paging hiệu quả hơn phía browser. (DataTables Plus)
Không nên viết:
$users = User::all();rồi:
collect($users)
->filter(...)
->sort(...)
->paginate(...);Đối với dữ liệu lớn, đây là cách không tốt.
Vì:
MySQL
↓
Tất cả records
↓
PHP RAM
↓
Filter
↓
Sort
↓
PaginationThay vào đó:
MySQL
↓
WHERE
ORDER BY
LIMIT
↓
Laravel
↓
BrowserDatabase phải làm phần lớn công việc.
Thay vì:
$users = User::all();hãy hình thành thói quen:
$query = User::query();Sau đó:
if (...) {
$query->where(...);
}
if (...) {
$query->orderBy(...);
}
$users = $query->paginate(10);Đây là tư duy rất quan trọng khi xây dựng Admin CMS.
Khi Controller bắt đầu dài, chúng ta có thể đưa logic filter vào Model.
Ví dụ:
class User extends Authenticatable
{
public function scopeSearch($query, $search)
{
return $query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
}Controller:
$query = User::query();
if ($search = request('search')) {
$query->search($search);
}Code Controller trở nên sạch hơn.
Phần Scope sẽ được học sâu hơn ở:
Trong dự án lớn hơn:
Controller
↓
Service
↓
Repository / Query
↓
Model
↓
DatabaseVí dụ:
UserController
↓
UserService
↓
User Query
↓
MySQLNhưng ở giai đoạn hiện tại không nên làm quá phức tạp.
Người mới học Laravel chỉ cần nắm chắc:
User::query()→
where()→
orderBy()→
paginate()là đã rất quan trọng.
Search cũng là dữ liệu từ người dùng.
Laravel 13 cung cấp nhiều cách validation request, từ $request->validate() đến Form Request cho các trường hợp phức tạp hơn. (Laravel)
Ví dụ:
$request->validate([
'search' => ['nullable', 'string', 'max:100'],
'role' => ['nullable', 'in:admin,editor,user'],
]);Không nhất thiết mọi ô search đều phải validation phức tạp, nhưng đây là thói quen tốt.
Ví dụ:
public function index(Request $request)
{
$request->validate([
'search' => ['nullable', 'string', 'max:100'],
'role' => ['nullable', 'in:admin,editor,user'],
'sort' => [
'nullable',
'in:latest,oldest,name_asc,name_desc'
],
]);
$query = User::query();
// SEARCH
if ($search = $request->search) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
// FILTER
if ($request->role) {
$query->where('role', $request->role);
}
// SORT
match ($request->sort) {
'oldest' => $query->oldest(),
'name_asc' => $query->orderBy('name'),
'name_desc' => $query->orderByDesc('name'),
default => $query->latest(),
};
// PAGINATION
$users = $query
->paginate(10)
->withQueryString();
return view('users.index', compact('users'));
}Đây là phiên bản khá tốt cho một CRUD Admin cơ bản.
Sau bài này, chúng ta đã đi từ:
User::all();đến:
User::query()và xây dựng:
USERS
│
↓
User::query()
│
┌──────────┼──────────┐
↓ ↓ ↓
Search Filter Sort
│ │ │
└──────────┼──────────┘
↓
Pagination
│
↓
BladeHoặc với DataTables:
Browser
│
↓
DataTables
│
AJAX
↓
Laravel
│
↓
Eloquent
│
↓
MySQL
│
↓
JSON
│
↓
DataTables
Tạo trang:
/userscó:
Search
PaginationThêm:
Filter Rolevới:
All
Admin
Editor
UserThêm:
Sortgồm:
Mới nhất
Cũ nhất
Tên A → Z
Tên Z → AKết hợp:
Search
+
Filter
+
Sort
+
Paginationvà đảm bảo khi chuyển trang các query vẫn được giữ lại.
Cài:
DataTablesvà hiển thị User bằng AJAX.
Chuyển DataTables sang:
serverSide: truevà để Laravel xử lý:
Search
Sort
Paginationthay vì tải toàn bộ User về browser.
Người học đã có thể xây dựng một bảng Admin hoàn chỉnh:
┌─────────────────────────────────────────────────────────────┐
│ 👥 USERS [+ Thêm User] │
├─────────────────────────────────────────────────────────────┤
│ │
│ 🔎 [ Tìm tên/email ] │
│ │
│ Role: [ Tất cả ▼ ] Sort: [ Mới nhất ▼ ] │
│ │
├────┬────────────────┬──────────────────┬─────────┬─────────┤
│ ID │ Họ tên │ Email │ Role │ Action │
├────┼────────────────┼──────────────────┼─────────┼─────────┤
│ 1 │ Nguyễn Văn A │ a@gmail.com │ Admin │ ✏️ 🗑️ │
│ 2 │ Trần Văn B │ b@gmail.com │ User │ ✏️ 🗑️ │
│ 3 │ Lê Văn C │ c@gmail.com │ Editor │ ✏️ 🗑️ │
└────┴────────────────┴──────────────────┴─────────┴─────────┘
1 2 3 4 5 Next →Đây là bước chuyển rất quan trọng từ CRUD cơ bản sang Admin Dashboard thực tế.
Ghi nhớ công thức:
$query = Model::query();
$query->where(...);
$query->orderBy(...);
$query->paginate(...);Và khi dữ liệu lớn:
DataTables
+
AJAX
+
serverSide
+
Laravel
+
MySQLsẽ trở thành một kiến trúc rất hữu ích cho các bảng quản trị lớn. (DataTables Plus)
Bài tiếp theo — Bài 30: Soft Delete sẽ xây dựng hệ thống Thùng rác (Trash) với SoftDeletes, restore() và forceDelete(), sau đó áp dụng trực tiếp vào CRUD Users/Posts.
x1
quay về MỤC LỤC
Trong các bài trước, chúng ta đã xây dựng được các chức năng CRUD cho:CategoriesPostsUsersTuy nhiên, khi dữ liệu bắt đầu nhiều lên, một bảng kiểu:User...
Trong các bài trước, chúng ta đã xây dựng CRUD Categories và CRUD Posts.
Ở bài này, chúng ta tiếp tục xây dựng một chức năng rất quan trọng của Blog CMS:
👤 Quản lý người dùng — User Management
Sau bài học, trang quản trị có thể:
👥 Hiển thị danh sách User
🔎 Tìm kiếm User
🛡️ Đổi Role
🔒 Khóa / mở khóa tài khoản
🔑 Reset Password
🗑️ Xóa User
📄 Phân trang
🔗 Kết hợp với Middleware / Authorization
Chúng ta sẽ xây dựng trang:
/admin/users
Giao diện quản trị dự kiến:
┌──────────────────────────────────────────────────────────────┐
│ 👥 Quản lý Users │
├──────────────────────────────────────────────────────────────┤
│ 🔍 [ Tìm kiếm... ] [ + Thêm User ] │
├────┬────────────────┬─────────────────┬─────────┬────────────┤
│ # │ User │ Email │ Role │ Trạng thái │
├────┼────────────────┼─────────────────┼─────────┼────────────┤
│ 1 │ Admin │ admin@gmail.com │ Admin │ 🟢 Active │
│ 2 │ Nguyễn Văn A │ a@gmail.com │ User │ 🔴 Locked │
│ 3 │ Trần Văn B │ b@gmail.com │ Editor │ 🟢 Active │
└────┴────────────────┴─────────────────┴─────────┴────────────┘
Người quản trị có thể thực hiện:
User
│
├── Xem danh sách
├── Tìm kiếm
├── Đổi Role
├── Khóa tài khoản
├── Mở khóa
├── Reset Password
└── Xóa
Chức năng User Management vẫn tuân theo mô hình MVC:
Browser
│
▼
Route
│
▼
UserController
│
▼
User Model
│
▼
MySQL
Sau đó Controller trả dữ liệu về Blade:
UserController
│
▼
resources/views/admin/users/
│
├── index.blade.php
├── edit.blade.php
└── ...
Laravel đã có bảng users từ lúc cài đặt Breeze.
Thông thường bảng có các trường:
users
├── id
├── name
├── email
├── email_verified_at
├── password
├── remember_token
├── created_at
└── updated_at
Tuy nhiên Blog CMS cần thêm thông tin quản trị.
Chúng ta cần ít nhất:
role
is_active
Trong đó:
roleXác định quyền của User:
admin
editor
user
is_activeXác định tài khoản có đang hoạt động hay không:
1 = Active
0 = Locked
Nếu project của bạn chưa có hai trường này, chạy:
php artisan make:migration add_role_and_is_active_to_users_table --table=users
Mở migration vừa tạo:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('role')
->default('user')
->after('email');
$table->boolean('is_active')
->default(true)
->after('role');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'role',
'is_active',
]);
});
}
};
Sau đó:
php artisan migrate
Sau migration:
users
│
├── id
├── name
├── email
├── role
├── is_active
├── email_verified_at
├── password
├── remember_token
├── created_at
└── updated_at
Ví dụ:
1 | Admin | admin@gmail.com | admin | 1
2 | Nguyễn A | a@gmail.com | user | 1
3 | Trần B | b@gmail.com | editor | 0
Mở:
app/Models/User.php
Thêm role và is_active vào $fillable.
protected $fillable = [
'name',
'email',
'password',
'role',
'is_active',
];
Laravel 12 cũng hỗ trợ khai báo cast theo cách hiện đại bằng casts().
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'is_active' => 'boolean',
];
}
Như vậy:
$user->is_active
sẽ trả về:
true
hoặc:
false
thay vì chỉ làm việc với 0 và 1.
Chúng ta sử dụng Resource Controller:
php artisan make:controller UserController --resource
Laravel tạo:
app/Http/Controllers/UserController.php
Resource Controller có các method:
index()
create()
store()
show()
edit()
update()
destroy()
Trong bài này chúng ta chủ yếu sử dụng:
index()
edit()
update()
destroy()
Mở:
app/Http/Controllers/UserController.php
Thêm:
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
Method index():
public function index(Request $request)
{
$search = $request->input('search');
// Các column được phép sort
$allowedSorts = [
'id',
'name',
'email',
'role',
'is_active',
'created_at',
];
// Column mặc định
$sort = $request->input('sort', 'created_at');
// Nếu column không hợp lệ → dùng created_at
if (! in_array($sort, $allowedSorts)) {
$sort = 'created_at';
}
// Direction mặc định
$direction = $request->input('direction', 'desc');
// Chỉ cho phép asc / desc
if (! in_array($direction, ['asc', 'desc'])) {
$direction = 'desc';
}
$users = User::query()
// SEARCH
->when($search, function ($query, $search) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
})
// SORT
->orderBy($sort, $direction)
// PAGINATION
->paginate(10)
->withQueryString();
return view('admin.users.index', compact(
'users',
'sort',
'direction'
));
}
Ở đây chúng ta sử dụng:
when()
để chỉ tìm kiếm khi người dùng nhập từ khóa.
Ví dụ:
/admin/users?search=nguyen
Laravel sẽ tìm:
name LIKE '%nguyen%'
hoặc:
email LIKE '%nguyen%'
Mở:
routes/web.php
Thêm:
use App\Http\Controllers\UserController;
Route::middleware('auth')->group(function () {
Route::resource('users', UserController::class);
});
Nếu muốn URL có dạng:
/admin/users
thì nên tổ chức route:
Route::middleware('auth')
->prefix('admin')
->name('admin.')
->group(function () {
Route::resource('users', UserController::class);
});
Khi đó:
GET /admin/users
GET /admin/users/create
POST /admin/users
GET /admin/users/{user}
GET /admin/users/{user}/edit
PUT/PATCH /admin/users/{user}
DELETE /admin/users/{user}
Tên route:
admin.users.index
admin.users.create
admin.users.store
admin.users.show
admin.users.edit
admin.users.update
admin.users.destroy
Tạo:
resources/views/users/index.blade.php
Ví dụ:
<x-app-layout>
<div class="max-w-7xl mx-auto py-8">
<x-ui.card
title="Quản lý Users"
description="Quản lý tài khoản người dùng">
{{-- SEARCH --}}
<form
action="{{ route('admin.users.index') }}"
method="GET"
class="mb-6">
<div class="flex gap-2">
<input
type="text"
name="search"
value="{{ request('search') }}"
placeholder="Tìm theo tên hoặc email..."
class="flex-1 rounded-lg border-gray-300">
<button
type="submit"
class="px-4 py-2 bg-gray-800 text-white rounded-lg">
🔍 Tìm kiếm
</button>
</div>
</form>
{{-- TABLE --}}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b">
{{-- ID --}}
<th class="text-left p-3">
<a
href="{{ request()->fullUrlWithQuery([
'sort' => 'id',
'direction' => request('sort') === 'id' && request('direction') === 'asc'
? 'desc'
: 'asc'
]) }}"
class="flex items-center gap-1 hover:text-blue-600">
#
@if (request('sort') === 'id')
{{ request('direction') === 'asc' ? '↑' : '↓' }}
@endif
</a>
</th>
{{-- USER --}}
<th class="text-left p-3">
<a
href="{{ request()->fullUrlWithQuery([
'sort' => 'name',
'direction' => request('sort') === 'name' && request('direction') === 'asc'
? 'desc'
: 'asc'
]) }}"
class="flex items-center gap-1 hover:text-blue-600">
User
@if (request('sort') === 'name')
{{ request('direction') === 'asc' ? '↑' : '↓' }}
@endif
</a>
</th>
{{-- EMAIL --}}
<th class="text-left p-3">
<a
href="{{ request()->fullUrlWithQuery([
'sort' => 'email',
'direction' => request('sort') === 'email' && request('direction') === 'asc'
? 'desc'
: 'asc'
]) }}"
class="flex items-center gap-1 hover:text-blue-600">
Email
@if (request('sort') === 'email')
{{ request('direction') === 'asc' ? '↑' : '↓' }}
@endif
</a>
</th>
{{-- ROLE --}}
<th class="text-left p-3">
<a
href="{{ request()->fullUrlWithQuery([
'sort' => 'role',
'direction' => request('sort') === 'role' && request('direction') === 'asc'
? 'desc'
: 'asc'
]) }}"
class="flex items-center gap-1 hover:text-blue-600">
Role
@if (request('sort') === 'role')
{{ request('direction') === 'asc' ? '↑' : '↓' }}
@endif
</a>
</th>
{{-- STATUS --}}
<th class="text-left p-3">
<a
href="{{ request()->fullUrlWithQuery([
'sort' => 'is_active',
'direction' => request('sort') === 'is_active' && request('direction') === 'asc'
? 'desc'
: 'asc'
]) }}"
class="flex items-center gap-1 hover:text-blue-600">
Trạng thái
@if (request('sort') === 'is_active')
{{ request('direction') === 'asc' ? '↑' : '↓' }}
@endif
</a>
</th>
{{-- ACTION --}}
<th class="text-right p-3">
Thao tác
</th>
</tr>
</thead>
<tbody>
@forelse ($users as $user)
<tr class="border-b">
<td class="p-3">
{{ $user->id }}
</td>
<td class="p-3 font-semibold">
{{ $user->name }}
</td>
<td class="p-3">
{{ $user->email }}
</td>
<td class="p-3">
@if ($user->role === 'admin')
🛡️ Admin
@elseif ($user->role === 'editor')
✏️ Editor
@else
👤 User
@endif
</td>
<td class="p-3">
@if ($user->is_active)
<span class="text-green-600">
🟢 Active
</span>
@else
<span class="text-red-600">
🔴 Locked
</span>
@endif
</td>
<td class="p-3">
<div class="flex justify-end gap-2">
<a
href="{{ route('admin.users.edit', $user) }}"
class="px-3 py-1 bg-blue-600 text-white rounded">
Sửa
</a>
<form
action="{{ route('admin.users.destroy', $user) }}"
method="POST"
onsubmit="return confirm('Bạn có chắc muốn xóa User này?')">
@csrf
@method('DELETE')
<button
type="submit"
class="px-3 py-1 bg-red-600 text-white rounded">
Xóa
</button>
</form>
</div>
</td>
</tr>
@empty
<tr>
<td
colspan="6"
class="p-6 text-center text-gray-500">
Không tìm thấy User.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
{{-- PAGINATION --}}
<div class="mt-6">
{{ $users->links() }}
</div>
</x-ui.card>
</div>
</x-app-layout>Trong update():
$request->validate([
'name' => [
'required',
'string',
'max:255',
],
'email' => [
'required',
'email',
'max:255',
'unique:users,email,' . $user->id,
],
'role' => [
'required',
'in:admin,editor,user',
],
'is_active' => [
'nullable',
'boolean',
],
]);
Điểm quan trọng:
'unique:users,email,' . $user->id
cho phép User giữ nguyên email hiện tại.
Ví dụ User:
id = 5
email = admin@gmail.com
khi sửa User số 5 vẫn được phép giữ:
admin@gmail.com
Method hoàn chỉnh:
public function update(Request $request, User $user)
{
$validated = $request->validate([
'name' => [
'required',
'string',
'max:255',
],
'email' => [
'required',
'email',
'max:255',
'unique:users,email,' . $user->id,
],
'role' => [
'required',
'in:admin,editor,user',
],
'is_active' => [
'nullable',
'boolean',
],
]);
$validated['is_active'] = $request->boolean('is_active');
$user->update($validated);
return redirect()
->route('admin.users.index')
->with('success', 'Cập nhật User thành công.');
}
Đây là chức năng rất quan trọng.
Không nhất thiết phải xóa User.
Thay vào đó:
is_active = 0
Ví dụ:
Nguyễn Văn A
email: a@gmail.com
role: user
is_active: 0
Tài khoản vẫn tồn tại trong database nhưng bị khóa.
Chúng ta có thể kiểm tra trong Middleware.
Ví dụ:
if (! auth()->user()->is_active) {
auth()->logout();
return redirect()
->route('login')
->withErrors([
'email' => 'Tài khoản của bạn đã bị khóa.',
]);
}
Tuy nhiên với hệ thống thực tế, nên tạo Middleware riêng.
Ví dụ:
php artisan make:middleware CheckUserActive
Sau đó đưa logic kiểm tra tài khoản vào Middleware.
Đây sẽ là nền tảng tốt để phát triển hệ thống quản trị hoàn chỉnh.
Admin cũng cần khả năng reset password cho User.
Laravel cung cấp:
Hash::make()
để hash password.
Import:
use Illuminate\Support\Facades\Hash;
Ví dụ:
$user->update([
'password' => Hash::make('12345678'),
]);
Tuy nhiên không nên hard-code password mặc định trong hệ thống thực tế.
Tốt hơn là Admin nhập password mới.
Trong form edit:
<div class="mb-6">
<label class="block font-semibold mb-2">
Password mới
</label>
<input
type="password"
name="password"
class="w-full rounded-lg border-gray-300"
placeholder="Để trống nếu không muốn đổi">
@error('password')
<p class="text-red-600 text-sm mt-1">
{{ $message }}
</p>
@enderror
</div>
Validation:
'password' => [
'nullable',
'string',
'min:8',
'confirmed',
],
Form cần thêm:
<input
type="password"
name="password_confirmation"
class="w-full rounded-lg border-gray-300">
Không nên làm:
$user->password = $request->password;
vì password sẽ được lưu trực tiếp.
Nên dùng:
$user->password = Hash::make($request->password);
Hoặc Laravel có thể tự hash nếu Model đã khai báo:
'password' => 'hashed',
trong casts().
Khi đó:
$user->update([
'password' => $request->password,
]);
Laravel sẽ xử lý việc hash.
Có thể viết:
public function update(Request $request, User $user)
{
$validated = $request->validate([
'name' => [
'required',
'string',
'max:255',
],
'email' => [
'required',
'email',
'max:255',
'unique:users,email,' . $user->id,
],
'role' => [
'required',
'in:admin,editor,user',
],
'password' => [
'nullable',
'string',
'min:8',
'confirmed',
],
'is_active' => [
'nullable',
'boolean',
],
]);
$validated['is_active'] = $request->boolean('is_active');
if (empty($validated['password'])) {
unset($validated['password']);
}
$user->update($validated);
return redirect()
->route('admin.users.index')
->with('success', 'Cập nhật User thành công.');
}
Nhờ:
unset($validated['password']);
nếu Admin không nhập password mới thì password cũ vẫn được giữ nguyên.
Method:
public function destroy(User $user)
{
$user->delete();
return redirect()
->route('admin.users.index')
->with('success', 'Đã xóa User.');
}
Form:
<form
action="{{ route('admin.users.destroy', $user) }}"
method="POST">
@csrf
@method('DELETE')
<button type="submit">
🗑️ Xóa
</button>
</form>
Đây là một lỗi logic rất dễ xảy ra.
Ví dụ Admin:
admin@gmail.com
đăng nhập vào Dashboard.
Sau đó bấm:
Xóa
chính tài khoản của mình.
Kết quả:
User bị xóa
↓
Session vẫn đang tồn tại
↓
Hệ thống có thể phát sinh lỗi
Do đó nên kiểm tra:
if ($user->id === auth()->id()) {
return back()
->with('error', 'Bạn không thể xóa chính tài khoản của mình.');
}
Method:
public function destroy(User $user)
{
if ($user->id === auth()->id()) {
return back()
->with('error', 'Bạn không thể xóa chính tài khoản của mình.');
}
$user->delete();
return redirect()
->route('admin.users.index')
->with('success', 'Đã xóa User.');
}
Tương tự:
Admin
↓
Khóa tài khoản
↓
is_active = false
Sau đó chính Admin sẽ không thể đăng nhập nữa.
Vì vậy cần ngăn:
if ($user->id === auth()->id()) {
...
}
Đây là một nguyên tắc quan trọng trong Admin CMS:
🔐 Không cho tài khoản hiện tại tự vô hiệu hóa quyền truy cập của chính mình.
Hiện tại:
Route::middleware('auth')
chỉ kiểm tra:
Đã đăng nhập?
Nhưng chưa kiểm tra:
Có phải Admin?
Ví dụ:
User thường
↓
/admin/users
↓
❌ Không được phép
Trong khi:
Admin
↓
/admin/users
↓
✅ Được phép
Đây chính là lý do chúng ta cần:
Authorization
đã học ở Bài 25.
Ví dụ tạo Middleware:
php artisan make:middleware AdminMiddleware
Logic:
public function handle($request, Closure $next)
{
if (! auth()->check()) {
return redirect()->route('login');
}
if (auth()->user()->role !== 'admin') {
abort(403);
}
return $next($request);
}
Sau đó bảo vệ khu vực Admin:
Route::middleware(['auth', 'admin'])
->prefix('admin')
->name('admin.')
->group(function () {
Route::resource('users', UserController::class);
});
adminLaravel 12 không dùng Kernel.php theo cách Laravel cũ. Alias middleware được khai báo trong:
bootstrap/app.php
Mở:
bootstrap/app.php
Bạn sẽ có dạng tương tự:
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
})
->withExceptions(function (Exceptions $exceptions): void {
//
})
->create();
Sửa thành:
<?php
use App\Http\Middleware\AdminMiddleware;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use App\Http\Middleware\AdminMiddleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'admin' => AdminMiddleware::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
})
->create();
Điểm quan trọng là:
$middleware->alias([
'admin' => AdminMiddleware::class,
]);
Guest
↓
Login
User
↓
403 Forbidden
Editor
↓
403 Forbidden
Admin
↓
✅ User Management
Sau bài này hệ thống có thể có:
┌─────────────┐
│ ADMIN │
└──────┬──────┘
│
┌─────────────┼──────────────┐
↓ ↓ ↓
Users Posts Categories
│
┌─────┼─────┐
↓ ↓ ↓
Role Lock Reset
Chạy:
php artisan route:list
Bạn sẽ thấy:
GET|HEAD admin/users
POST admin/users
GET|HEAD admin/users/{user}/edit
PUT|PATCH admin/users/{user}
DELETE admin/users/{user}
Nếu muốn lọc:
php artisan route:list --path=admin
Nếu muốn kiểm tra giao diện với nhiều User:
php artisan tinker
Sau đó:
User::factory()
->count(30)
->create();
Nếu muốn tạo Admin:
User::factory()->create([
'name' => 'Administrator',
'email' => 'admin@example.com',
'role' => 'admin',
'is_active' => true,
]);
Trong:
database/factories/UserFactory.php
có thể thêm:
'role' => 'user',
'is_active' => true,
Ví dụ:
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password
??= Hash::make('password'),
'remember_token' => Str::random(10),
'role' => 'user',
'is_active' => true,
];
}
Một hệ thống CMS nên có ít nhất một Admin.
Trong:
database/seeders/DatabaseSeeder.php
có thể tạo:
User::factory()->create([
'name' => 'Admin',
'email' => 'admin@example.com',
'role' => 'admin',
'is_active' => true,
]);
Sau đó:
php artisan db:seed
Hoặc:
php artisan migrate:fresh --seed
⚠️ migrate:fresh --seed sẽ xóa toàn bộ database rồi tạo lại.
Chỉ sử dụng khi đang phát triển hoặc khi bạn thực sự muốn reset database.
Sau Bài 28:
👥 USER MANAGEMENT
│
├── Danh sách User
│
├── 🔍 Search
│
├── 📄 Pagination
│
├── ✏️ Edit User
│
├── 🛡️ Đổi Role
│
├── 🔒 Lock / Unlock
│
├── 🔑 Reset Password
│
└── 🗑️ Delete User
Database:
users
│
├── id
├── name
├── email
├── role
├── is_active
├── password
├── created_at
└── updated_at
Project Blog CMS lúc này có thể có:
app/
├── Http/
│ ├── Controllers/
│ │ ├── CategoryController.php
│ │ ├── PostController.php
│ │ └── UserController.php
│ │
│ └── Middleware/
│ ├── AdminMiddleware.php
│ └── CheckUserActive.php
│
├── Models/
│ ├── Category.php
│ ├── Post.php
│ └── User.php
│
resources/
└── views/
├── categories/
│ ├── index.blade.php
│ ├── create.blade.php
│ └── edit.blade.php
│
├── posts/
│ ├── index.blade.php
│ ├── create.blade.php
│ └── edit.blade.php
│
└── users/
├── index.blade.php
└── edit.blade.php
Khi Admin truy cập:
/admin/users
Laravel xử lý:
Browser
│
▼
Route
│
▼
auth Middleware
│
▼
admin Middleware
│
▼
UserController@index()
│
▼
User Model
│
▼
MySQL
│
▼
$users
│
▼
users/index.blade.php
│
▼
Browser
Khi sửa:
/admin/users/10/edit
luồng:
Route
↓
UserController@edit
↓
User Model
↓
User #10
↓
users/edit.blade.php
Khi lưu:
PUT /admin/users/10
↓
UserController@update
↓
Validation
↓
User::update()
↓
MySQL
↓
Redirect
↓
/admin/users
Tạo trang:
/admin/users
hiển thị:
ID
Name
Email
Role
Status
Action
Thêm tìm kiếm:
Name
Email
Thêm:
Pagination 10 User/page
Cho phép Admin đổi:
User
Editor
Admin
Thêm:
Active
Locked
Không cho Admin:
Tự xóa mình
Không cho Admin:
Tự khóa mình
Thêm reset password:
Password mới
Confirm Password
Trong bài này chúng ta đã xây dựng một User Management cơ bản cho Blog CMS.
Các kiến thức quan trọng:
Resource Controller
↓
User Model
↓
Validation
↓
Search
↓
Pagination
↓
Role
↓
Account Status
↓
Password Reset
↓
Authorization
Điểm quan trọng nhất của bài này không chỉ là CRUD User.
Chúng ta bắt đầu chuyển từ:
CRUD thông thường
sang:
QUẢN TRỊ HỆ THỐNG
Một User không còn đơn giản chỉ là:
name
email
password
mà bắt đầu có:
ROLE
STATUS
PERMISSION
AUTHORIZATION
Đây chính là nền móng để xây dựng một Admin Dashboard thực tế.
Blog CMS hiện đã có:
BLOG CMS
│
┌────────────┼────────────┐
↓ ↓ ↓
Categories Posts Users
│ │ │
↓ ↓ ↓
CRUD CRUD CRUD
│
┌─────────┼─────────┐
↓ ↓ ↓
Role Lock Password
Bài 29 — Search & DataTables sẽ nâng cấp hệ thống danh sách hiện tại, đặc biệt là Users và Posts, với:
🔎 Search nâng cao
↕️ Sort
🏷️ Filter
📄 Pagination
📊 DataTables.net
⚡ Tương tác bảng dữ liệu chuyên nghiệp hơn
x1
Trong các bài trước, chúng ta đã xây dựng CRUD Categories và CRUD Posts.Ở bài này, chúng ta tiếp tục xây dựng một chức năng rất quan trọng của Blog CM...
Đâ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.
resources/
└── views/
└── components/
└── ui/
├── input.blade.php
├── textarea.blade.php
├── select.blade.php
├── button.blade.php
└── card.blade.php
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>
resources/views/components/ui/input.blade.php
@props([
'label',
'name',
'type' => 'text',
'value' => null,
])
<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, $value) }}"
{{ $attributes->merge([
'class' =>
'inline-block 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>
@props([
'label',
'name',
'rows' => 4,
'value' => null,
])
<div class="mb-5">
<label
for="{{ $name }}"
class="block text-sm font-semibold text-gray-700 mb-2">
{{ $label }}
</label>
<textarea
id="{{ $name }}"
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, $value) }}</textarea>
@error($name)
<p class="text-red-600 text-sm mt-2">
{{ $message }}
</p>
@enderror
</div>
@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-600sẽ không được build nếu không safelist. An toàn hơn là dùng@classhoặcmatchđể ánh xạ màu.
@props([
'label',
'name',
'options' => [],
'placeholder' => null,
'multiple' => false,
])
<label
for="{{ $name }}"
class="block text-sm font-semibold text-gray-700 mb-2">
{{ $label }}
</label>
<select
id="{{ $name }}"
name="{{ $multiple ? $name . '[]' : $name }}"
@if($multiple) multiple @endif
{{ $attributes->merge([
'class' =>
'inline-block 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'
]) }}>
@if($placeholder && !$multiple)
<option value="">
{{ $placeholder }}
</option>
@endif
@foreach($options as $key => $option)
@php
/*
* Eloquent model
*/
if (is_object($option)) {
$value = $option->id;
$text = $option->name;
}
/*
* Array:
* ['value' => 'draft', 'label' => 'Nháp']
*/
elseif (is_array($option)) {
$value = $option['value'];
$text = $option['label'];
}
/*
* Associative array:
* 'draft' => 'Nháp'
*/
else {
$value = $key;
$text = $option;
}
@endphp
<option
value="{{ $value }}"
@selected(
$multiple
? in_array(
$value,
old($name, [])
)
: old($name) == $value
)>
{{ $text }}
</option>
@endforeach
</select>
@error($name)
<p class="text-red-600 text-sm mt-2">
{{ $message }}
</p>
@enderror Ghi chú: chọn 01.
Ghi chú: chọn nhiều mục.
Chạy:
php artisan make:component Ui/SelectMultiple
Laravel sẽ tạo:
app/View/Components/Ui/SelectMultiple.php
resources/views/components/ui/select-multiple.blade.php
SelectMultiple.phpComponent này nhận $options và $value, đồng thời chuẩn hóa $value thành array.
<?php
namespace App\View\Components\Ui;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class SelectMultiple extends Component
{
public function __construct(
public string $label,
public string $name,
public $options = [],
public $value = [],
) {
$this->value = is_array($value)
? $value
: (empty($value) ? [] : [$value]);
}
public function render(): View|Closure|string
{
return view('components.ui.select-multiple');
}
}
resources/views/components/ui/select-multiple.blade.php@php
$selectedValues = collect($value)->map(fn ($id) => (string) $id)->toArray();
@endphp
<div class="mb-3">
<label for="{{ $name }}" class="form-label">
{{ $label }}
</label>
<select
id="{{ $name }}"
name="{{ $name }}[]"
class="form-select @error($name) is-invalid @enderror"
multiple
>
@foreach ($options as $option)
@php
$optionValue = is_array($option)
? $option['value']
: ($option->id ?? $option);
$optionLabel = is_array($option)
? $option['label']
: ($option->name ?? $option->title ?? $option);
@endphp
<option
value="{{ $optionValue }}"
@selected(in_array((string) $optionValue, $selectedValues, true))
>
{{ $optionLabel }}
</option>
@endforeach
</select>
@error($name)
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
Điểm quan trọng nhất ở đây là:
name="{{ $name }}[]"
Do đó khi submit form, Laravel sẽ nhận:
tags => [
1,
3,
5,
]
thay vì một giá trị đơn.
<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>
✅ 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.');
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:
Có hiệu ứng trượt xuống.
Tự mờ dần rồi biến mất.
Có nút ✕ để đóng thủ công.
Không cần JavaScript riêng, chỉ vài dòng Alpine.
Đó 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ó.
Đâ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ả C...
🥤 Bài 1 — Làm quen với máy bán nước tự động🥤 Bài 2 — Thiết kế khung máy bán nước mini🥤 Bài 3 — Thiết kế cơ cấu trục xoắn (Spiral Dispenser)🥤 Bài 4 — C...
Hai con voi về "thủ phủ sầu riêng" Ea Knuếc làm lễ cầu no đủ, livetream bán hàng, trong khi địa phương chuẩn bị 10 container sầu riêng đạt chuẩn kết nối trực tiếp sang thị trường Trung Quốc.
Dù công nghệ số giúp việc mua bán, ký kết hợp đồng diễn ra nhanh hơn, song không ít doanh nghiệp vẫn băn khoăn rủi ro pháp lý, đặc biệt là các chứng cứ điện tử khi xảy ra tranh chấp.
OpenAI ra mắt phiên bản ChatGPT dành cho người 13-17 tuổi, tăng lớp bảo vệ và cho phụ huynh quản lý tài khoản nhưng không được xem nội dung trò chuyện.
Sáng 19-8, Chương trình “Trở về thời khắc thiêng liêng” tại Dinh Độc Lập giúp người dân được sống trong khoảng khắc “Bác Hồ đọc Tuyên ngôn độc lập” bằng công nghệ thực tế ảo.
Tổng công ty Bưu điện Việt Nam - Vietnam Post vừa thông báo chính thức mở rộng hoạt động sang lĩnh vực viễn thông di động.
🕒 Cập nhật: Thứ Tư, ngày 19 tháng 08 năm 2026 lúc 16:38
Cập nhật giá vàng hôm nay chiều 19/8 chứng kiến đợt điều chỉnh giảm mạnh trên diện rộng. Cả giá vàng SJC lẫn giá ...
🕒 Cập nhật: Thứ Tư, ngày 19 tháng 08 năm 2026 lúc 16:09
Cập nhật giá iPhone 16 Pro Max tháng 8, trong bối cảnh iPhone 17 Pro Max chưa rẻ, iPhone 16 Pro Max cũ vẫn là lựa...
🕒 Cập nhật: Thứ Tư, ngày 19 tháng 08 năm 2026 lúc 10:54
Không chỉ duy trì mức giá rẻ so với giai đoạn tăng nóng trước đó hồi đầu tháng 4, các mặt hàng xăng dầu trong nướ...
🕒 Cập nhật: Thứ Tư, ngày 19 tháng 08 năm 2026 lúc 15:39
Từ áo dài, Rồng, Phượng đến những danh lam, thắng cảnh quen thuộc, OMG 3Q - VNG lần đầu đưa dấu ấn Việt vào thế g...
🕒 Cập nhật: Thứ Tư, ngày 19 tháng 08 năm 2026 lúc 15:28
Nếu tài chính của bạn không quá mạnh để mua iPhone 17, một chiếc iPhone 16 kém hơn chút về công nghệ nhưng độ mạn...