📘 Laravel 12 (2026) — BÀI 29 — SEARCH, SORT, FILTER, PAGINATION & DATATABLES
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...
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
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
Nếu Laravel vẫn nhớ view cũ -> reset bằng lệnh php artisan view:clear
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('admin.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:
<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>
Controller:
switch (request('sort')) {
case 'oldest':
$query->oldest();
break;
case 'name_asc':
$query->orderBy('name', 'asc');
break;
case 'name_desc':
$query->orderBy('name', '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 'oldest':
$query->oldest();
break;
case 'name_asc':
$query->orderBy('name', 'asc');
break;
case 'name_desc':
$query->orderBy('name', '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('users.index') }}">
Xóa bộ lọc
</a>Khi click:
/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('users.index') }}',
columns: [
{
data: 'id'
},
{
data: 'name'
},
{
data: 'email'
},
{
data: 'role'
},
{
data: 'created_at'
}
]
});
});DataTables sẽ gửi request:
/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('users.index');
}Khi AJAX request:
/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('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('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ý:
$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...
Sau 9 bài, chúng ta đã lần lượt xây dựng:
Cơ cấu nhả lon.
Motor và driver.
ESP32.
Encoder/Sensor.
State Machine.
Màn hình cảm ứng.
Thanh toán QR.
Backend Laravel.
Quản lý tồn kho.
Bây giờ chúng ta ghép tất cả lại thành một hệ thống vending machine mini hoàn chỉnh.
Mục tiêu của bài này không phải làm một chiếc máy thương mại ngay lập tức.
Mục tiêu là:
Làm cho một người có thể chọn một lon nước → thanh toán → máy tự động nhả đúng một lon → cảm biến xác nhận → hệ thống cập nhật tồn kho.
Hệ thống cuối cùng:
👤 KHÁCH HÀNG
│
▼
┌───────────────┐
│ TOUCH SCREEN │
└───────┬───────┘
│
Chọn sản phẩm
│
▼
┌───────────────┐
│ ESP32 │
└───────┬───────┘
│
HTTP / HTTPS
│
▼
┌───────────────┐
│ LARAVEL │
│ SERVER │
└───────┬───────┘
│
Tạo Order
│
▼
QR
│
▼
💳 Thanh toán
│
▼
Payment = PAID
│
▼
ESP32
│
▼
┌───────────────┐
│ MOTOR DRIVER │
└───────┬───────┘
│
▼
MOTOR
│
▼
TRỤC XOẮN
│
▼
🥤
│
▼
SENSOR
│
▼
Product Delivered
│
▼
Laravel
│
▼
Stock - 1
Đây chính là kiến trúc IoT vending machine cơ bản.
Để học, chúng ta không cần làm máy lớn.
Một phiên bản nhỏ có thể gồm:
ESP32
Màn hình cảm ứng
Motor DC hoặc Stepper
Motor Driver
IR Sensor
Encoder/Hall Sensor
Nguồn DC
Nút dừng khẩn cấp
Cơ cấu trục xoắn
Khung máy
Có thể bắt đầu với một ngăn duy nhất.
Ví dụ:
┌──────────────────────────┐
│ │
│ 🥤 🥤 🥤 🥤 │
│ 🥤 🥤 🥤 🥤 │
│ │
│ TRỤC XOẮN │
│ ════════════════════ │
│ │
│ ↓ │
│ SENSOR │
│ ↓ │
│ ┌────────┐ │
│ │ NHẬN │ │
│ │ HÀNG │ │
│ └────────┘ │
└──────────────────────────┘
Sau khi chạy ổn định mới nhân lên:
A1
A2
B1
B2
Đây là cách chúng ta tránh việc code trở thành một mớ hỗn độn.
Touch Screen
Nhiệm vụ:
Hiển thị sản phẩm.
Nhận thao tác.
Hiển thị QR.
Hiển thị trạng thái.
State Machine
Nhiệm vụ:
Quyết định máy đang ở trạng thái nào.
Chuyển trạng thái.
Kiểm soát timeout.
Xử lý lỗi.
Motor
Sensor
Encoder
Nhiệm vụ:
Chạy motor.
Dừng motor.
Đọc sensor.
Phát hiện sản phẩm.
Laravel
Database
Payment
Inventory
Admin
Nhiệm vụ:
Quản lý sản phẩm.
Quản lý giá.
Tạo Order.
Xác nhận thanh toán.
Quản lý tồn kho.
Ghi log.
Quản lý máy.
Đây là phần quan trọng nhất.
┌──────────┐
│ IDLE │
└────┬─────┘
↓
┌──────────┐
│ SELECT │
└────┬─────┘
↓
┌───────────────┐
│ CREATE_ORDER │
└───────┬───────┘
↓
┌───────────────┐
│ SHOW_QR │
└───────┬───────┘
↓
┌─────────────────┐
│ WAIT_PAYMENT │
└────────┬────────┘
│
┌───────┴───────┐
↓ ↓
TIMEOUT PAID
↓ ↓
IDLE DISPENSE
↓
CHECK
↙ ↘
SUCCESS ERROR
↓ ↓
IDLE ERROR
Toàn bộ máy sẽ xoay quanh State Machine này.
IDLEKhi không có người sử dụng:
IDLE
Màn hình:
┌──────────────────────────────┐
│ │
│ 🥤 DRINK │
│ │
│ MÁY BÁN NƯỚC │
│ │
│ Chạm để bắt đầu │
│ │
└──────────────────────────────┘
Không có giao dịch nào đang chạy.
SELECTKhách chọn:
A1 — Coca
ESP32 lưu:
selectedSlot = "A1";
Nhưng chưa nhả hàng.
ESP32 hỏi Backend:
GET /api/machines/VM001/products/A1
Server trả:
{
"code": "A1",
"name": "Coca",
"price": 15000,
"stock": 5,
"status": "active"
}
Nếu:
stock > 0
thì tiếp tục.
Nếu:
stock = 0
hiển thị:
HẾT HÀNG
và quay lại:
SELECT
Nếu còn hàng:
A1
Coca
15.000đ
ESP32 gọi:
POST /api/orders
Server tạo:
Order #10001
Trạng thái:
payment_status = pending
delivery_status = pending
Server trả về thông tin giao dịch.
ESP32 hiển thị:
┌──────────────────────────────┐
│ COCA │
│ │
│ 15.000đ │
│ │
│ ┌───────────┐ │
│ │ │ │
│ │ QR │ │
│ │ │ │
│ └───────────┘ │
│ │
│ Quét để thanh toán │
│ │
└──────────────────────────────┘
State:
WAIT_PAYMENT
ESP32 định kỳ hỏi:
GET /api/orders/10001
Server trả:
{
"payment_status": "pending"
}
Tiếp tục chờ.
Khi:
{
"payment_status": "paid"
}
ESP32 chuyển:
WAIT_PAYMENT
↓
DISPENSE
Ví dụ:
120 giây
Nếu khách không thanh toán:
WAIT_PAYMENT
↓
TIMEOUT
↓
IDLE
Màn hình:
Giao dịch đã hết thời gian.
Vui lòng thử lại.
Khi thanh toán thành công:
DISPENSE
ESP32:
Motor ON
Motor quay:
↻
↻
↻
Trục xoắn đẩy lon về phía trước.
Nếu dùng encoder, ESP32 có thể biết motor đã quay bao nhiêu.
Ví dụ:
Target = 1000 pulses
Motor chạy:
0
100
200
300
...
900
1000
Khi:
pulse >= target
thì:
motorStop();
Encoder chỉ cho biết:
Motor đã quay đúng vị trí.
Nó không chắc chắn:
Lon đã thực sự rơi xuống hay chưa.
Ví dụ:
Motor
↓
Trục xoắn quay
↓
Lon bị kẹt
Encoder vẫn có thể báo:
OK
Vì vậy cần thêm:
Sensor xác nhận sản phẩm.
Ví dụ sử dụng IR Sensor:
🥤
↓
↓
┌─────────┐
│ IR │
│ SENSOR │
└────┬────┘
↓
PRODUCT_DETECTED
ESP32 chờ:
Sensor = DETECTED
Chúng ta cần:
Motor position = OK
AND
Product sensor = DETECTED
Khi cả hai đúng:
DELIVERY SUCCESS
Ví dụ:
Motor → OK
Sensor → NO PRODUCT
Sau khoảng:
5 giây
máy chuyển:
ERROR
Màn hình:
┌──────────────────────────────┐
│ ! │
│ │
│ KHÔNG THỂ GIAO HÀNG │
│ │
│ Vui lòng liên hệ │
│ nhân viên hỗ trợ. │
└──────────────────────────────┘
Đây là nguyên tắc rất quan trọng.
Nếu:
Payment = PAID
Delivery = FAILED
thì:
stock không được giảm
Ví dụ:
Stock = 5
Thanh toán
↓
Motor lỗi
↓
Sensor không phát hiện
Stock vẫn = 5
Nhưng Order phải được lưu:
payment_status = paid
delivery_status = failed
Để hệ thống xử lý tiếp.
Nếu:
Sensor = DETECTED
thì:
delivery_status = success
Sau đó:
stock = stock - 1
và ghi:
InventoryLog
Ví dụ:
A1
SALE
-1
Order #10001
Màn hình:
┌──────────────────────────────┐
│ │
│ ✓ │
│ │
│ GIAO HÀNG THÀNH CÔNG │
│ │
│ Vui lòng lấy hàng │
│ │
└──────────────────────────────┘
Sau vài giây:
SUCCESS
↓
IDLE
Kiến trúc API tối thiểu:
/api
│
├── machines
│
├── products
│
├── orders
│
├── payments
│
└── inventory
Ví dụ:
POST /api/orders
GET /api/orders/{id}
GET /api/machines/{code}
GET /api/machines/{code}/products
POST /api/machines/{code}/events
Ví dụ:
public function store(Request $request)
{
$machine = Machine::where(
'code',
$request->machine_code
)->firstOrFail();
$machineProduct = MachineProduct::where(
'machine_id',
$machine->id
)
->where('slot', $request->slot)
->firstOrFail();
if ($machineProduct->stock <= 0) {
return response()->json([
'message' => 'Out of stock'
], 422);
}
$order = Order::create([
'machine_id' => $machine->id,
'machine_product_id' => $machineProduct->id,
'amount' => $machineProduct->product->price,
'payment_status' => 'pending',
'delivery_status' => 'pending',
]);
return response()->json($order);
}
Điểm quan trọng:
Giá lấy từ Database, không lấy từ ESP32.
public function show(Order $order)
{
return response()->json([
'id' => $order->id,
'payment_status' => $order->payment_status,
'delivery_status' => $order->delivery_status,
]);
}
ESP32 chỉ cần biết:
pending
paid
expired
và:
pending
dispensing
success
failed
Sau khi cảm biến phát hiện lon:
POST /api/orders/10001/delivery
Dữ liệu:
{
"status": "success"
}
Server xử lý:
Order
↓
delivery_status = success
↓
stock - 1
↓
InventoryLog
Tất cả nên nằm trong một transaction.
Ví dụ:
Order #10001
Machine:
VM001
Slot:
A1
Product:
Coca
Amount:
15.000đ
Payment:
PAID
Delivery:
SUCCESS
Created:
21:10:01
Paid:
21:10:14
Delivered:
21:10:17
Đây là dữ liệu rất có giá trị cho hệ thống quản lý sau này.
Không nên viết tất cả trong main.cpp.
Có thể chia:
esp32/
│
├── main.cpp
│
├── config.h
│
├── machine/
│ ├── state_machine.cpp
│ └── state_machine.h
│
├── motor/
│ ├── motor.cpp
│ └── motor.h
│
├── sensor/
│ ├── sensor.cpp
│ └── sensor.h
│
├── display/
│ ├── display.cpp
│ └── display.h
│
├── api/
│ ├── api.cpp
│ └── api.h
│
└── payment/
├── payment.cpp
└── payment.h
Cấu trúc này sẽ giúp dự án dễ mở rộng.
main.cppKhông nên có hàng nghìn dòng code.
Ý tưởng:
void setup()
{
machine.begin();
motor.begin();
sensor.begin();
display.begin();
api.begin();
}
void loop()
{
machine.update();
}
State Machine sẽ điều phối các thành phần còn lại.
Ví dụ:
enum MachineState
{
IDLE,
SELECT,
CREATE_ORDER,
SHOW_QR,
WAIT_PAYMENT,
DISPENSE,
CHECK,
SUCCESS,
ERROR
};
Biến:
MachineState state = IDLE;
update()Ý tưởng:
void Machine::update()
{
switch (state)
{
case IDLE:
handleIdle();
break;
case SELECT:
handleSelect();
break;
case CREATE_ORDER:
handleCreateOrder();
break;
case WAIT_PAYMENT:
handlePayment();
break;
case DISPENSE:
handleDispense();
break;
case CHECK:
handleCheck();
break;
case SUCCESS:
handleSuccess();
break;
case ERROR:
handleError();
break;
}
}
Đây là cách tốt hơn rất nhiều so với:
if (...)
{
}
else if (...)
{
}
else if (...)
{
}
else if (...)
{
}
hàng trăm lần trong loop().
delay() cho toàn bộ máyKhông nên:
motorOn();
delay(5000);
motorOff();
Trong thời gian delay():
UI có thể không phản hồi.
Sensor không được xử lý tốt.
Network bị gián đoạn.
Payment không được kiểm tra.
Thay vào đó sử dụng:
millis()
hoặc một state machine không blocking.
ESP32 có thể kết nối:
Wi-Fi
Luồng:
ESP32
↓
Wi-Fi Router
↓
Internet
↓
Laravel Server
Trong phiên bản mini đặt trên bàn học, Wi-Fi là lựa chọn đơn giản nhất.
Đây là một lỗi phải tính đến.
Ví dụ:
Khách chọn Coca
↓
ESP32 mất Wi-Fi
Không được cho máy hoạt động bình thường như chưa có chuyện gì.
Màn hình:
┌──────────────────────────────┐
│ │
│ ⚠ MẤT KẾT NỐI │
│ │
│ Vui lòng thử lại sau. │
│ │
└──────────────────────────────┘
Tình huống khó hơn:
Khách thanh toán
↓
Payment = PAID
↓
ESP32 mất Wi-Fi
Không được tự động tạo Order mới.
Order cũ vẫn tồn tại trên Server.
Khi ESP32 kết nối lại:
GET Order #10001
Server trả:
PAID
Máy tiếp tục:
DISPENSE
Đây là lý do Order ID phải được lưu trên ESP32 trong quá trình giao dịch.
Một máy cơ khí thực tế nên có:
Nút dừng khẩn cấp.
Ví dụ:
🔴
EMERGENCY STOP
Khi nhấn:
Motor OFF
ngay lập tức.
Không chờ:
State Machine
xử lý xong.
Đây là một lớp bảo vệ phần cứng riêng.
Không cấp motor trực tiếp từ chân GPIO ESP32.
Sai:
ESP32 GPIO
│
▼
MOTOR
Đúng:
Power Supply
│
├──────────► Motor Driver ───► Motor
│
└──────────► Voltage Regulator ───► ESP32
ESP32 chỉ gửi:
PWM
DIR
ENABLE
tới driver.
Nếu dùng nguồn riêng cho motor và ESP32, cần thiết kế mass đúng cách.
Thông thường:
ESP32 GND
│
├──────── Motor Driver GND
│
└──────── Sensor GND
Nhưng nguồn và driver phải được thiết kế phù hợp với dòng tải và nhiễu của motor.
Đừng bắt đầu bằng:
20 sản phẩm
QR thật
Camera
4G
NFC
Cloud
Dashboard
AI
Chúng ta chỉ cần:
1 sản phẩm
1 motor
1 sensor
1 ESP32
1 màn hình
1 Backend
1 QR demo
Mục tiêu:
Một lon nước được bán thành công từ đầu đến cuối.
Sau khi chạy ổn mới mở rộng.
┌───────────────────────┐
│ │
│ 🥤 COCA │
│ │
│ 15.000đ │
│ │
│ [ MUA NGAY ] │
│ │
└──────────┬────────────┘
│
▼
Laravel
│
▼
QR
│
▼
Thanh toán
│
▼
ESP32
│
▼
Motor
│
▼
🥤
│
▼
Sensor
│
▼
SUCCESS
Nếu V1 chạy được:
Dự án đã thành công về mặt nguyên lý.
Sau đó thêm:
A1
A2
B1
B2
Kiến trúc:
ESP32
│
┌─────────┼─────────┐
↓ ↓ ↓
Motor A Motor B Motor C
│ │ │
Sensor A Sensor B Sensor C
Thêm:
QR thật
Inventory
Admin
Multiple machines
Remote monitoring
Lúc này:
CLOUD
│
┌────────┴────────┐
│ │
VM001 VM002
│ │
ESP32 ESP32
│ │
Vending Vending
Laravel Admin có thể hiển thị:
┌──────────────────────────────────────────────┐
│ VENDING DASHBOARD │
├──────────────────────────────────────────────┤
│ │
│ 🟢 VM001 🟢 VM002 🔴 VM003 │
│ Online Online Offline │
│ │
│ Doanh thu hôm nay: 1.250.000đ │
│ Đơn hàng: 87 │
│ Giao thành công: 84 │
│ Lỗi giao hàng: 3 │
│ │
├──────────────────────────────────────────────┤
│ LOW STOCK │
│ │
│ VM001 / B1 / Sprite / Còn 1 │
│ VM002 / A2 / Pepsi / Còn 0 │
└──────────────────────────────────────────────┘
Đây đã không còn đơn thuần là một Arduino project.
Nó trở thành:
Một hệ thống IoT có thiết bị + Backend + Database + Payment + Dashboard.
👤
│
▼
TOUCH SCREEN
│
▼
ESP32
│
┌──────────────┼──────────────┐
│ │ │
▼ ▼ ▼
Motor Sensor Wi-Fi
│ │ │
▼ ▼ ▼
Cơ khí IR/Encoder INTERNET
│
▼
┌─────────────┐
│ LARAVEL │
└──────┬──────┘
│
┌────────────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
ORDERS PAYMENT INVENTORY
│ │ │
└────────────────────────┼──────────────────┘
│
▼
ADMIN DASHBOARD
Chúng ta đã đi từ:
Một motor
đến:
Một hệ thống vending machine hoàn chỉnh về nguyên lý.
Cụ thể:
Khung
Trục xoắn
Motor
Driver
Cơ cấu nhận hàng
ESP32
Sensor
Encoder
Nguồn
Driver
Emergency Stop
State Machine
UI
HTTP
API
Laravel
Database
Order
Payment
Inventory
Admin
Đừng làm ngay một máy 10–20 ngăn.
Hãy làm đúng một ngăn duy nhất.
[ ] ESP32
[ ] Motor
[ ] Motor Driver
[ ] IR Sensor
[ ] Encoder/Hall Sensor
[ ] Màn hình
[ ] Nút Emergency Stop
[ ] Trục xoắn
[ ] IDLE
[ ] SELECT
[ ] CREATE_ORDER
[ ] SHOW_QR
[ ] WAIT_PAYMENT
[ ] DISPENSE
[ ] CHECK
[ ] SUCCESS
[ ] ERROR
[ ] Products
[ ] Machines
[ ] Machine Products
[ ] Orders
[ ] Inventory Logs
[ ] Payment
[ ] Chọn Coca
[ ] Kiểm tra tồn kho
[ ] Tạo Order
[ ] Hiển thị QR
[ ] Giả lập PAID
[ ] Motor quay
[ ] Sensor phát hiện lon
[ ] Ghi SUCCESS
[ ] Trừ stock
[ ] Ghi Inventory Log
[ ] Quay về IDLE
Nếu toàn bộ các bước này chạy ổn:
🏆 Bạn đã có một vending machine mini hoạt động đúng nguyên lý.
Chúng ta đã xây dựng dự án theo đúng hướng của một sản phẩm kỹ thuật:
Bài 1
↓
Ý tưởng vending machine mini
Bài 2
↓
Cấu trúc và nguyên lý cơ khí
Bài 3
↓
Cơ cấu nhả lon
Bài 4
↓
Motor + ESP32
Bài 5
↓
Cảm biến + phản hồi
Bài 6
↓
State Machine
Bài 7
↓
Touch UI
Bài 8
↓
QR Payment
Bài 9
↓
Inventory
Bài 10
↓
GHÉP TOÀN BỘ HỆ THỐNG
Và kiến trúc cuối cùng là:
🥤 VENDING MACHINE
│
┌────────────┼────────────┐
│ │ │
MECHANICAL ELECTRONIC SOFTWARE
│ │ │
Motor ESP32 State Machine
Spiral Sensor Touch UI
Frame Driver API
│ │ │
└────────────┼────────────┘
│
INTERNET
│
▼
LARAVEL
│
┌────────────┼────────────┐
│ │ │
ORDER PAYMENT INVENTORY
│ │ │
└────────────┼────────────┘
│
▼
ADMIN PANEL
Đây là mốc kết thúc phần "làm một vending machine mini chạy được".
Từ đây, phần tiếp theo có thể chuyển sang thiết kế cơ khí thực tế, PCB, nguồn, hộp máy, nhiều ngăn, làm lạnh, chống kẹt lon, giám sát từ xa và đưa máy từ prototype thành sản phẩm có thể chế tạo.
p/S: đọc xong bài này mình thấy chặn con cảm biến hồng ngoại chỗ lon rơi là mua 01 lon được nhiều lon...có ai thử chưa?
Sau 9 bài, chúng ta đã lần lượt xây dựng:Cơ cấu nhả lon.Motor và driver.ESP32.Encoder/Sensor.State Machine.Màn hình cảm ứng.Thanh toán QR.Backend Lara...
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.
Nghiên cứu cho thấy các nhóm AI có thể tự nghiêng về một lựa chọn chung, dù không có thủ lĩnh, phần thưởng hay yêu cầu phải đạt đồng thuận.
Gần 1/5 thanh thiếu niên và người trẻ Mỹ từng hỏi chatbot AI về sức khỏe tâm thần, trong đó nhiều người xem lời khuyên là hữu ích.
Điều hòa là thiết bị có giá trị lớn, thường được sử dụng liên tục trong nhiều năm. Theo các chuyên gia điện lạnh, để có thể an tâm sử dụng lâu dài, người tiêu dùng không nên bỏ qua 3 lưu ý quan trọng dưới đây khi chọn mua điều hòa.
Cần Thơ đưa vào hoạt động 27 điểm phát sóng công cộng 5G và thí điểm mạng 6G tại nhiều địa điểm nổi tiếng của địa phương.
🕒 Cập nhật: Thứ Ba, ngày 18 tháng 08 năm 2026 lúc 16:30
Nếu đang tìm kiếm iPhone cao cấp đặc biệt là về camera nhưng giá không đắt, mẫu iPhone với hệ khung viền thép phẳ...
🕒 Cập nhật: Thứ Ba, ngày 18 tháng 08 năm 2026 lúc 16:22
ĐTCL mùa 18 đang bước vào giai đoạn thử nghiệm sôi động với hàng loạt đội hình mới liên tục được khám phá. Trong ...
🕒 Cập nhật: Thứ Ba, ngày 18 tháng 08 năm 2026 lúc 15:39
Ngày 18/8/2026, Esports Foundation (EF) chính thức thông báo giải đấu Esports Nations Cup (ENC), dự kiến diễn ra ...
🕒 Cập nhật: Thứ Ba, ngày 18 tháng 08 năm 2026 lúc 14:33
Chỉ còn một tuần nữa là vòng bảng LCK 2026 kết thúc, cuộc đua giành ngôi đầu bảng Legend và cạnh tranh suất Play-...
🕒 Cập nhật: Thứ Ba, ngày 18 tháng 08 năm 2026 lúc 12:02
Nếu nhu cầu của bạn là giải trí trên một chiếc màn hình lớn, iPhone XS Max hoàn toàn có thể là một lựa chọn ngon,...