📘 BÀI 28 — CRUD USERS TRONG LARAVEL 12 LTS
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...
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 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/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');
$users = User::query()
->when($search, function ($query, $search) {
$query->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
})
->latest()
->paginate(10)
->withQueryString();
return view('users.index', compact('users'));
}
Ở đâ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">
<th class="text-left p-3">
#
</th>
<th class="text-left p-3">
User
</th>
<th class="text-left p-3">
Email
</th>
<th class="text-left p-3">
Role
</th>
<th class="text-left p-3">
Trạng thái
</th>
<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>
Form:
<form method="GET">
sẽ gửi:
/admin/users?search=admin
Controller nhận:
$search = $request->input('search');
Sau đó:
->when($search, function ($query, $search) {
$query->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
})
Ví dụ database:
Nguyễn Văn A
admin@gmail.com
editor@gmail.com
Tìm:
admin
sẽ trả về:
admin@gmail.com
Method:
public function edit(User $user)
{
return view('users.edit', compact('user'));
}
Laravel tự động Route Model Binding:
User $user
Ví dụ:
/admin/users/5/edit
Laravel tự tìm:
User::findOrFail(5);
Tạo:
resources/views/users/edit.blade.php
<x-app-layout>
<div class="max-w-3xl mx-auto py-8">
<x-ui.card
title="Chỉnh sửa User"
description="Cập nhật thông tin tài khoản">
<form
action="{{ route('admin.users.update', $user) }}"
method="POST">
@csrf
@method('PUT')
{{-- NAME --}}
<div class="mb-4">
<label class="block font-semibold mb-2">
Họ tên
</label>
<input
type="text"
name="name"
value="{{ old('name', $user->name) }}"
class="w-full rounded-lg border-gray-300">
@error('name')
<p class="text-red-600 text-sm mt-1">
{{ $message }}
</p>
@enderror
</div>
{{-- EMAIL --}}
<div class="mb-4">
<label class="block font-semibold mb-2">
Email
</label>
<input
type="email"
name="email"
value="{{ old('email', $user->email) }}"
class="w-full rounded-lg border-gray-300">
@error('email')
<p class="text-red-600 text-sm mt-1">
{{ $message }}
</p>
@enderror
</div>
{{-- ROLE --}}
<div class="mb-4">
<label class="block font-semibold mb-2">
Role
</label>
<select
name="role"
class="w-full rounded-lg border-gray-300">
<option
value="user"
@selected(old('role', $user->role) === 'user')>
👤 User
</option>
<option
value="editor"
@selected(old('role', $user->role) === 'editor')>
✏️ Editor
</option>
<option
value="admin"
@selected(old('role', $user->role) === 'admin')>
🛡️ Admin
</option>
</select>
</div>
{{-- STATUS --}}
<div class="mb-6">
<label class="block font-semibold mb-2">
Trạng thái
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
name="is_active"
value="1"
@checked(old('is_active', $user->is_active))>
<span>
🟢 Tài khoản đang hoạt động
</span>
</label>
</div>
{{-- BUTTON --}}
<div class="flex gap-2">
<button
type="submit"
class="px-5 py-2 bg-blue-600 text-white rounded-lg">
💾 Lưu thay đổi
</button>
<a
href="{{ route('admin.users.index') }}"
class="px-5 py-2 bg-gray-200 rounded-lg">
Hủy
</a>
</div>
</form>
</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);
});
Khi đó:
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
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.
@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>
Ghi chú: chọn nhiều mục.
<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...
Ở bài 8, máy đã có thể:
Cho khách chọn sản phẩm.
Tạo đơn hàng.
Hiển thị QR.
Nhận xác nhận thanh toán.
Nhả sản phẩm.
Xác nhận giao hàng.
Nhưng còn một vấn đề rất quan trọng:
Máy phải biết trong mỗi ngăn còn bao nhiêu sản phẩm.
Nếu không quản lý tồn kho, khách có thể thanh toán một sản phẩm đã hết và máy mới phát hiện ra không thể giao hàng.
Giả sử máy có 4 ngăn:
A1 — Coca — 5
A2 — Pepsi — 4
B1 — Sprite — 2
B2 — Sting — 0
Trên màn hình:
┌─────────────────────────────┐
│ CHỌN SẢN PHẨM │
│ │
│ ┌────────┐ ┌────────┐ │
│ │ Coca │ │ Pepsi │ │
│ │ 15.000 │ │ 15.000 │ │
│ │ CÒN 5 │ │ CÒN 4 │ │
│ └────────┘ └────────┘ │
│ │
│ ┌────────┐ ┌────────┐ │
│ │ Sprite │ │ Sting │ │
│ │ 15.000 │ │HẾT HÀNG│ │
│ │ CÒN 2 │ │ │ │
│ └────────┘ └────────┘ │
└─────────────────────────────┘
Sản phẩm hết hàng phải được khóa ngay trên giao diện.
Đây là điểm rất quan trọng.
Ban đầu:
A1 = 5
Bán một lon:
5 → 4
Bán tiếp:
4 → 3
...
1 → 0
Đây là cách đơn giản nhất.
Có thể đặt cảm biến để phát hiện sản phẩm thực tế.
Ví dụ:
🥤
🥤
🥤
🥤
🥤
Cảm biến ở cuối ngăn phát hiện còn sản phẩm hay không.
Hoặc dùng nhiều cảm biến:
🥤
──── Sensor 1
🥤
──── Sensor 2
🥤
──── Sensor 3
Cách này phức tạp hơn nhưng chính xác hơn trong một số thiết kế.
Ở phiên bản học tập, chúng ta nên bắt đầu bằng:
Phần mềm quản lý số lượng + cảm biến xác nhận sản phẩm rơi.
Không cần lắp một cảm biến cho từng lon ngay từ đầu.
Luồng:
Bán hàng thành công
↓
Giảm tồn kho 1
↓
Cảm biến xác nhận lon rơi
↓
Hoàn tất
Giả sử:
Tồn kho = 5
Khách thanh toán thành công.
Nếu ngay lập tức:
5 → 4
nhưng motor bị kẹt thì thực tế vẫn còn 5 lon.
Vì vậy tốt hơn là:
Thanh toán thành công
↓
Bắt đầu nhả hàng
↓
Cảm biến xác nhận
↓
Giảm tồn kho
Đây là một nguyên tắc quan trọng:
Chỉ ghi nhận đã bán khi sản phẩm thực sự được giao.
Trong Laravel, chúng ta có thể tạo bảng products.
Ví dụ:
products
id
code
name
price
stock
status
created_at
updated_at
Dữ liệu:
1 | A1 | Coca | 15000 | 5 | active
2 | A2 | Pepsi | 15000 | 4 | active
3 | B1 | Sprite | 15000 | 2 | active
4 | B2 | Sting | 15000 | 0 | active
code?Không nên chỉ dùng:
id = 1
Máy cần một mã dễ hiểu:
A1
A2
B1
B2
Ví dụ:
machine_products
machine_id | product_id | slot
VM001 | 1 | A1
VM001 | 2 | A2
Cách này còn tốt hơn nếu sau này có nhiều máy.
Ví dụ:
Coca
VM001 → A1
VM002 → B2
VM003 → A4
Vì vậy không nên lưu:
products.stock
nếu stock đại diện cho tồn kho của từng máy.
Nên tách:
products
và:
machine_products
machine_productsVí dụ:
machine_products
id
machine_id
product_id
slot
stock
capacity
status
Dữ liệu:
1 | VM001 | Coca | A1 | 5 | 6 | active
2 | VM001 | Pepsi | A2 | 4 | 6 | active
3 | VM001 | Sprite | B1 | 2 | 6 | active
4 | VM001 | Sting | B2 | 0 | 6 | out_of_stock
capacity để làm gì?Ví dụ một ngăn chứa tối đa 6 lon:
capacity = 6
Hiện tại:
stock = 4
Có nghĩa:
Đã bán = 2
Còn = 4
Có thể nạp = 2
Dashboard có thể hiển thị:
A2
4 / 6
████████░░
Có thể sử dụng:
active
out_of_stock
maintenance
disabled
Ví dụ:
A1 → active
A2 → active
B1 → out_of_stock
B2 → maintenance
Hai trạng thái cuối đều không cho khách mua.
Khi khách chọn A1:
SELECT A1
Server kiểm tra:
if ($machineProduct->stock <= 0) {
return response()->json([
'message' => 'Sản phẩm đã hết hàng.'
], 422);
}
Nếu còn:
stock > 0
mới tạo Order.
Ví dụ ESP32 gửi:
{
"product": "A1"
}
Server tự tìm:
A1
↓
Coca
↓
15.000đ
↓
stock = 5
Không nên để ESP32 gửi:
{
"product": "A1",
"price": 1000,
"stock": 99
}
Server phải là nơi quyết định dữ liệu quan trọng.
Giả sử:
A1 = 5
Khách mua.
Cảm biến xác nhận:
PRODUCT_DETECTED
Server cập nhật:
5 → 4
Ví dụ Laravel:
$machineProduct->decrement('stock');
Nếu:
stock = 1
Khách mua thành công:
1 → 0
Server chuyển:
status = out_of_stock
Giao diện:
┌──────────────┐
│ COCA │
│ │
│ HẾT HÀNG │
└──────────────┘
stock = 0Có thể xảy ra:
stock = 0
status = active
Điều này không tốt.
Tốt hơn là khi cập nhật:
if ($machineProduct->stock <= 0) {
$machineProduct->update([
'stock' => 0,
'status' => 'out_of_stock',
]);
}
Ví dụ:
A1 = 2
Nhân viên nạp thêm 4 lon:
2 + 4 = 6
Dashboard có nút:
[NẠP HÀNG]
Nhập:
Số lượng: 4
Server cập nhật:
stock = 6
status = active
Máy lập tức cho phép bán lại.
Một hệ thống thực tế nên có lịch sử nhập/xuất kho.
Ví dụ:
inventory_logs
id
machine_product_id
type
quantity
reason
created_at
Ví dụ:
08:00 RESTOCK +6
10:15 SALE -1
10:30 SALE -1
11:02 SALE -1
12:20 RESTOCK +3
Ta có thể biết chính xác:
Tại sao hôm nay A1 còn 6 lon?
Có thể dùng:
RESTOCK
SALE
ADJUSTMENT
DAMAGE
EXPIRED
RETURN
Ví dụ:
RESTOCK +6
SALE -1
DAMAGE -1
Ví dụ:
InventoryLog::create([
'machine_product_id' => $machineProduct->id,
'type' => 'SALE',
'quantity' => -1,
'reason' => 'Order #' . $order->id,
]);
Sau đó:
$machineProduct->decrement('stock');
Như vậy hệ thống vừa:
cập nhật tồn kho
lưu lịch sử
Đây là phần rất quan trọng.
Không nên:
Giảm stock
↓
Tạo log
↓
Lỗi
và cuối cùng dữ liệu bị lệch.
Laravel nên sử dụng database transaction:
DB::transaction(function () use ($machineProduct, $order) {
$machineProduct->decrement('stock');
InventoryLog::create([
'machine_product_id' => $machineProduct->id,
'type' => 'SALE',
'quantity' => -1,
'reason' => 'Order #' . $order->id,
]);
});
Nếu một thao tác lỗi:
Toàn bộ transaction được rollback.
Giả sử chỉ còn:
stock = 1
Hai khách cùng lúc mua A1.
Nếu xử lý không cẩn thận:
Khách A → thấy 1
Khách B → thấy 1
A mua
B mua
1 → 0 → -1
Không được phép xảy ra.
Server cần khóa hoặc cập nhật dữ liệu một cách an toàn.
Ví dụ sử dụng transaction và lockForUpdate():
DB::transaction(function () use ($machineProduct) {
$machineProduct = MachineProduct::whereKey(
$machineProduct->id
)
->lockForUpdate()
->first();
if ($machineProduct->stock <= 0) {
throw new RuntimeException('Out of stock');
}
$machineProduct->decrement('stock');
});
Đây là một kỹ thuật rất quan trọng khi hệ thống có nhiều người dùng cùng thao tác.
Đến đây phần mềm đã quản lý được:
A1 = 5
Nhưng máy vẫn cần biết sản phẩm thực tế có rơi hay không.
Đó là nhiệm vụ của cảm biến.
Ví dụ:
🥤
🥤
🥤
🥤
🥤
↓
┌─────────┐
│ IR │
│ SENSOR │
└─────────┘
Khi lon đi qua:
Sensor = DETECTED
ESP32 gửi:
{
"machine": "VM001",
"slot": "A1",
"event": "product_delivered"
}
Vì hai thứ kiểm tra hai vấn đề khác nhau.
Theo hệ thống, còn bao nhiêu?
Sản phẩm có thực sự được giao không?
Kết hợp:
DATABASE
+
SENSOR
=
INVENTORY CONTROL
Ví dụ:
Payment = PAID
↓
Motor ON
↓
Encoder = OK
↓
Motor OFF
↓
IR Sensor = NO PRODUCT
Không được giảm kho.
Order:
payment_status = paid
delivery_status = failed
Máy có thể:
Thử lại
↓
Nếu vẫn lỗi
↓
Báo nhân viên
Không cần chờ stock = 0.
Ví dụ:
stock <= 2
thì:
LOW STOCK
Dashboard:
⚠ A1 — Coca
Còn 2 / 6
Có thể đặt:
stock > 2
→ NORMAL
stock <= 2
→ LOW STOCK
stock = 0
→ OUT OF STOCK
Sau này có thể cho mỗi sản phẩm một mức riêng:
min_stock = 2
Laravel Admin có thể hiển thị:
┌─────────────────────────────────────────────┐
│ INVENTORY │
├──────┬──────────┬───────┬────────┬─────────┤
│ Slot │ Product │ Stock │ Status │ Action │
├──────┼──────────┼───────┼────────┼─────────┤
│ A1 │ Coca │ 5/6 │ Normal │ │
│ A2 │ Pepsi │ 2/6 │ Low │ │
│ B1 │ Sprite │ 0/6 │ Empty │ │
│ B2 │ Sting │ 4/6 │ Normal │ │
└──────┴──────────┴───────┴────────┴─────────┘
Khi có nhiều vending machine:
VM001
├── A1 Coca 5
├── A2 Pepsi 2 ⚠
├── B1 Sprite 0 ❌
└── B2 Sting 6
VM002
├── A1 Coca 4
├── A2 Pepsi 5
├── B1 Sprite 3
└── B2 Sting 0 ❌
Admin có thể nhìn thấy ngay:
Máy nào cần nạp hàng.
Khi:
stock <= min_stock
Server có thể tạo:
Restock Task
Ví dụ:
TASK #1025
Machine: VM001
Slot: B1
Product: Sprite
Current: 0
Capacity: 6
→ CẦN NẠP 6 LON
Đây là bước đầu tiên để xây dựng hệ thống quản lý vận hành thực tế.
Bây giờ một giao dịch sẽ đi qua:
Khách chọn sản phẩm
↓
Kiểm tra tồn kho
↓
Tạo Order
↓
Thanh toán QR
↓
Payment = PAID
↓
ESP32 DISPENSE
↓
Motor quay
↓
Encoder xác nhận vị trí
↓
IR Sensor xác nhận lon
↓
Delivery = SUCCESS
↓
Stock - 1
↓
Inventory Log
↓
Kiểm tra Low Stock
↓
Hoàn tất
Đây là một luồng rất gần với cách chúng ta sẽ xây dựng phiên bản thực tế.
Chưa cần làm cảm biến tồn kho phức tạp.
Hãy xây dựng trước phần Backend.
A1 — Coca — 15.000đ
A2 — Pepsi — 15.000đ
B1 — Sprite — 15.000đ
B2 — Sting — 15.000đ
VM001
A1 → Coca
A2 → Pepsi
B1 → Sprite
B2 → Sting
A1 = 6
A2 = 6
B1 = 3
B2 = 0
Trên giao diện máy:
B2 → HẾT HÀNG
và không cho phép chọn.
Giả lập một giao dịch A1 thành công.
6 → 5
Tạo một InventoryLog:
SALE -1
Thử giảm:
3 → 2
Giao diện Admin phải hiển thị:
LOW STOCK
Sau bài 9, máy của chúng ta đã có thêm một hệ thống quan trọng:
VENDING MACHINE
ESP32
│
┌────────────┼────────────┐
↓ ↓ ↓
Motor Sensor UI
│ │
└──────┬─────┘
↓
SERVER
│
┌──────┼──────┐
↓ ↓ ↓
Order Stock Logs
│ │
└──────┼──────┘
↓
ADMIN PANEL
Điểm quan trọng nhất:
Thanh toán thành công chưa đồng nghĩa với bán hàng thành công. Chỉ khi cảm biến xác nhận sản phẩm đã được giao thì hệ thống mới trừ tồn kho.
Và từ đây, máy đã bắt đầu có khả năng tự vận hành và báo tình trạng cho người quản lý.
Bài tiếp theo sẽ ghép toàn bộ những gì đã học:
Màn hình
↓
Chọn sản phẩm
↓
Laravel
↓
QR Payment
↓
Xác nhận thanh toán
↓
ESP32
↓
Motor + Encoder
↓
Cảm biến
↓
Giao sản phẩm
↓
Trừ kho
↓
Laravel Admin
Đây sẽ là bài lắp ghép toàn bộ hệ thống thành một vending machine mini hoàn chỉnh, thay vì chỉ học từng linh kiện riêng lẻ.
Ở bài 8, máy đã có thể:Cho khách chọn sản phẩm.Tạo đơn hàng.Hiển thị QR.Nhận xác nhận thanh toán.Nhả sản phẩm.Xác nhận giao hàng.Nhưng còn một vấn đề ...
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ứ Hai, ngày 17 tháng 08 năm 2026 lúc 16:44
Lenovo Legion Y700 Infinite vừa bất ngờ xuất hiện trong loạt ảnh thực tế trước thời điểm ra mắt chính thức. Mẫu t...
🕒 Cập nhật: Thứ Hai, ngày 17 tháng 08 năm 2026 lúc 16:30
Trong bối cảnh iPhone 18 sắp ra mắt, chiếc iPhone này là một lựa chọn giá rẻ rất đáng lưu tâm trong tháng 8 khi c...
🕒 Cập nhật: Thứ Hai, ngày 17 tháng 08 năm 2026 lúc 16:22
Tencent và Krafton vừa công bố PUBG Mobile Light, phiên bản rút gọn của PUBG Mobile được thiết kế để giảm đáng kể...
🕒 Cập nhật: Thứ Hai, ngày 17 tháng 08 năm 2026 lúc 16:09
Hanwha Life Esports đánh bại KT Rolster với tỷ số 2-1 tại vòng 4 LCK 2026, qua đó cán mốc 17 chiến thắng và chính...
🕒 Cập nhật: Thứ Hai, ngày 17 tháng 08 năm 2026 lúc 15:58
Dù ra mắt đã lâu nhưng chiếc iPhone 12 Mini vẫn mạnh đến mức không chỉ cập nhật iOS 27 mới nhất mà còn so kè khôn...