🥤 Bài 8 — Thanh toán QR cho máy bán nước tự động
Ở các bài trước, máy của chúng ta đã có:ESP32 điều khiển.Motor và cơ cấu trục xoắn.Encoder/Hall Sensor.Cảm biến phát hiện sản phẩm.State Machine.Giao ...
Dành cho những ai chưa biết về lập trình hay các bạn muốn chuyển sang nghề thiết kế web
Dành cho những ai chưa biết về lập trình hay các bạn muốn chuyển sang nghề thiết kế web
Ở các bài trước, máy của chúng ta đã có:
ESP32 điều khiển.
Motor và cơ cấu trục xoắn.
Encoder/Hall Sensor.
Cảm biến phát hiện sản phẩm.
State Machine.
Giao diện cảm ứng.
Bây giờ chúng ta thêm một thành phần rất quan trọng:
Thanh toán QR.
Mục tiêu cuối cùng là:
Khách chọn Coca
↓
Máy tạo giao dịch
↓
Hiển thị QR
↓
Khách quét QR
↓
Thanh toán thành công
↓
Server xác nhận
↓
ESP32 nhận lệnh
↓
Nhả CocaĐây là nguyên tắc quan trọng.
Không nên thiết kế:
QR
↓
ESP32
↓
Ngân hàng
ESP32 không nên trực tiếp xử lý:
thông tin tài khoản ngân hàng
thông tin thẻ
chữ ký giao dịch
khóa bí mật API
xác thực thanh toán
Thay vào đó, chúng ta cần một Backend Server.
Kiến trúc:
KHÁCH
│
▼
Quét mã QR
│
▼
Cổng thanh toán
│
▼
SERVER
│
▼
ESP32
│
▼
MOTOR
│
▼
🥤
Giả sử Coca có giá:
15.000đ
ESP32 yêu cầu server tạo một giao dịch:
POST /api/orders
Dữ liệu:
{
"machine": "VM001",
"product": "A1",
"amount": 15000
}
Server tạo:
Order #10025
Sau đó trả về thông tin thanh toán.
Một sai lầm rất nguy hiểm là:
Hiển thị QR
↓
Khách quét
↓
ESP32 tự cho rằng đã thanh toán
Không được.
Khách có thể:
chụp ảnh QR.
gửi ảnh cho người khác.
không thanh toán.
thanh toán sai số tiền.
Máy chỉ được nhả hàng khi:
Server xác nhận giao dịch hợp lệ.
Khi khách chọn:
A1
Coca
15.000đ
ESP32 gửi:
machine = VM001
product = A1
amount = 15000
Server tạo một Order:
orders
id: 10025
machine: VM001
product: A1
amount: 15000
status: pending
Ban đầu:
pending
Server có thể tạo dữ liệu QR dựa trên giao dịch.
Ví dụ:
VietQR
Nội dung có thể chứa:
Ngân hàng
Số tài khoản
Số tiền
Nội dung chuyển khoản
Ví dụ nội dung:
VM001-10025
Khách chuyển:
15.000đ
với nội dung:
VM001-10025
Server sẽ dùng thông tin giao dịch để xác định tiền thuộc về đơn hàng nào.
Không nên dùng:
COCA
làm nội dung thanh toán.
Vì trong một ngày có thể có:
COCA
COCA
COCA
COCA
Thay vào đó:
VM001-10025
VM001-10026
VM001-10027
Mỗi giao dịch có một ID riêng.
Ở bài 6 chúng ta có:
IDLE
↓
SELECT
↓
PAYMENT
↓
DISPENSE
↓
CHECK
↓
SUCCESS
Bây giờ PAYMENT chi tiết hơn:
SELECT
↓
CREATE_ORDER
↓
SHOW_QR
↓
WAIT_PAYMENT
↓
PAYMENT_SUCCESS
↓
DISPENSE
Nếu hết thời gian:
WAIT_PAYMENT
↓
TIMEOUT
↓
IDLE
Không thể để máy chờ vô hạn.
Ví dụ:
Thời gian thanh toán:
120 giây
Nếu khách không thanh toán:
PAYMENT TIMEOUT
Máy trở về:
IDLE
Đơn hàng:
pending
có thể được đánh dấu:
expired
Có hai cách phổ biến.
Cổng thanh toán gửi thông báo cho server:
Payment Provider
↓
POST /api/payment/webhook
↓
Laravel
Server kiểm tra:
Giao dịch có tồn tại?
Số tiền có đúng?
Mã giao dịch có đúng?
Trạng thái có thành công?
Chữ ký có hợp lệ?
Nếu tất cả đúng:
status = paid
Nếu hệ thống thanh toán hoặc kiến trúc của bạn không thuận tiện cho webhook trực tiếp đến thiết bị, ESP32 có thể hỏi server:
GET /api/orders/10025
Server trả:
{
"id": 10025,
"status": "paid"
}
ESP32:
pending
↓
pending
↓
pending
↓
paid
Khi nhận:
paid
thì:
DISPENSE
| Phương pháp | Ưu điểm | Nhược điểm |
|---|---|---|
| Webhook | Nhanh, hiệu quả | Backend phải nhận callback |
| Polling | Dễ hiểu, dễ làm prototype | Tốn request |
| Kết hợp | Linh hoạt | Kiến trúc phức tạp hơn |
Đối với phiên bản học tập, chúng ta có thể bắt đầu bằng:
ESP32 polling Server
Sau khi hệ thống chạy ổn định mới chuyển sang kiến trúc nâng cao.
Vì chúng ta đang học Laravel, Backend có thể xây dựng bằng Laravel.
Ví dụ:
POST /api/orders
GET /api/orders/{order}
POST /api/payment/webhook
Controller:
public function store(Request $request)
{
$order = Order::create([
'machine_code' => $request->machine,
'product_code' => $request->product,
'amount' => $request->amount,
'status' => 'pending',
]);
return response()->json([
'order_id' => $order->id,
'status' => $order->status,
]);
}
Sau này có thể bổ sung:
QR data.
Transaction ID.
Expired time.
Payment provider.
Signature.
Một bảng orders đơn giản:
orders
id
machine_code
product_code
amount
status
transaction_code
paid_at
created_at
updated_at
Ví dụ:
10025
VM001
A1
15000
paid
VM001-10025
2026-08-09 12:05:32
ESP32 gửi:
{
"product": "A1",
"amount": 15000
}
Nhưng server không nên tin tuyệt đối amount.
Ví dụ người dùng sửa request thành:
{
"product": "A1",
"amount": 100
}
Nếu server tin dữ liệu này thì máy có thể bị khai thác.
Server phải lấy giá từ database:
A1 → Coca → 15.000đ
Sau đó tự xác định:
amount = 15000
Nên thiết kế:
ESP32
│
│ product=A1
▼
Laravel
│
│ tìm giá A1
▼
15.000đ
│
▼
Create Order
│
▼
Generate QR
│
▼
Customer Payment
│
▼
Payment Provider
│
▼
Webhook
│
▼
Laravel
│
▼
Order = PAID
│
▼
ESP32
│
▼
DISPENSE
Ví dụ:
bool checkPayment(int orderId)
{
// Gửi HTTP request đến server
// Server trả về:
// pending
// paid
// expired
return false;
}
Trong State Machine:
case WAIT_PAYMENT:
if (checkPayment(orderId)) {
currentState = DISPENSE;
}
break;
delay()Không nên:
delay(5000);
rồi kiểm tra thanh toán.
Thay vào đó:
if (millis() - lastCheck >= 2000) {
lastCheck = millis();
checkPayment(orderId);
}
ESP32 có thể đồng thời:
cập nhật màn hình.
kiểm tra cảm ứng.
kiểm tra thanh toán.
xử lý cảm biến.
giám sát motor.
Đây là tình huống rất quan trọng.
Ví dụ:
Khách thanh toán 15.000đ
↓
Payment = PAID
↓
Motor chạy
↓
Lon bị kẹt
Không được chỉ hiện:
ERROR
và kết thúc.
Server phải ghi nhận:
payment = paid
delivery = failed
Sau đó hệ thống có thể:
thử nhả lại.
đánh dấu cần hoàn tiền.
thông báo nhân viên.
ghi log sự cố.
Nên tách thanh toán và giao hàng.
Ví dụ:
payment_status
----------------
pending
paid
failed
expired
refunded
và:
delivery_status
----------------
pending
dispensing
success
failed
Như vậy:
PAID + DELIVERY_FAILED
không bị nhầm thành:
PAYMENT_FAILED
Ví dụ khách mua A1:
12:10:01
SELECT A1
12:10:02
CREATE ORDER #10025
12:10:03
SHOW QR
12:10:15
PAYMENT PAID
12:10:16
DISPENSING
12:10:18
PRODUCT DETECTED
12:10:18
DELIVERY SUCCESS
Server có thể lưu toàn bộ lịch sử.
Vì Backend dùng Laravel, sau này chúng ta có thể tạo:
ADMIN DASHBOARD
Hiển thị:
Doanh thu hôm nay
------------------
1.250.000đ
Đơn hàng
------------------
87
Đã giao
------------------
84
Lỗi
------------------
3
Danh sách:
Order Machine Product Amount Status
10025 VM001 A1 15.000 SUCCESS
10026 VM001 B2 20.000 SUCCESS
10027 VM001 A1 15.000 ERROR
Đây chính là bước kết nối dự án máy bán nước với những kiến thức Laravel Admin CRUD mà chúng ta đã học trước đó.
Ngay từ đầu nên có:
machine_code
Ví dụ:
VM001
VM002
VM003
Server sẽ biết:
Order #10025
Machine:
VM002
Sau này một hệ thống Laravel có thể quản lý:
SERVER
┌───────┼───────┐
↓ ↓ ↓
VM001 VM002 VM003
↓ ↓ ↓
ESP32 ESP32 ESP32
Đây chính là nền tảng để biến một mô hình học tập thành hệ thống IoT vending machine.
Khi đưa máy ra Internet, không được để:
ESP32 → API
mà không xác thực.
Có thể dùng:
API Key
hoặc token riêng cho từng máy:
VM001
TOKEN_XXXXXXXX
Server kiểm tra token trước khi trả dữ liệu.
Ngoài ra:
Chỉ sử dụng HTTPS.
Không lưu secret thanh toán trong firmware nếu không cần.
Không cho ESP32 tự quyết định giao dịch đã thanh toán.
Kiểm tra số tiền ở server.
Kiểm tra mã giao dịch.
Chống xử lý một giao dịch hai lần.
Giả sử ESP32 hỏi:
Order #10025 = paid
và nhận được:
paid
ESP32 nhả hàng.
Nhưng do mạng chập chờn, ESP32 lại hỏi lần nữa:
Order #10025 = paid
Nếu chương trình không cẩn thận:
Motor
↓
Lon 1
Motor
↓
Lon 2
Để tránh điều này, Order cần có trạng thái giao hàng:
delivery_status = dispensing
Sau khi giao thành công:
delivery_status = success
ESP32 không được nhả lại Order đã có:
success
Sau bài 8:
┌──────────┐
│ IDLE │
└────┬─────┘
↓
┌──────────┐
│ SELECT │
└────┬─────┘
↓
┌──────────────┐
│ CREATE_ORDER │
└──────┬───────┘
↓
┌──────────────┐
│ SHOW_QR │
└──────┬───────┘
↓
┌────────────────┐
│ WAIT_PAYMENT │
└───────┬────────┘
│
┌─────────┴─────────┐
↓ ↓
TIMEOUT PAID
↓ ↓
IDLE DISPENSE
↓
CHECK
↙ ↘
SUCCESS ERROR
↓ ↓
IDLE SUPPORT
Đây đã là một luồng giao dịch hoàn chỉnh.
Chưa cần kết nối ngân hàng thật.
Hãy xây dựng trước một phiên bản giả lập thanh toán:
Khách chọn:
A1
Laravel tạo:
Order #10001
với:
status = pending
Màn hình ESP32 hiển thị:
15.000đ
[ QR DEMO ]
Tạo một nút trong Admin:
[ XÁC NHẬN THANH TOÁN ]
Admin bấm nút.
Database chuyển:
pending
↓
paid
ESP32 nhận:
paid
và chuyển:
WAIT_PAYMENT
↓
DISPENSE
Motor nhả sản phẩm.
Như vậy bạn có thể kiểm tra toàn bộ kiến trúc thanh toán mà chưa cần kết nối dịch vụ thanh toán thật.
Sau bài 8, kiến trúc của máy đã tiến thêm một bước lớn:
INTERNET
│
▼
┌────────────┐
│ LARAVEL │
│ SERVER │
└─────┬──────┘
│
Payment API
│
▼
QR
│
▼
KHÁCH
│
Thanh toán
│
▼
┌────────────┐
│ LARAVEL │
└─────┬──────┘
│
PAID
│
▼
ESP32
│
▼
MOTOR
│
▼
🥤
Điểm quan trọng nhất của bài này là:
Thanh toán và nhả hàng phải là hai hệ thống riêng biệt, được kết nối bằng trạng thái giao dịch.
Đừng để ESP32 tự quyết định "đã thanh toán". Server phải xác nhận giao dịch, sau đó ESP32 mới được phép nhả sản phẩm.
Ở bài tiếp theo, chúng ta sẽ giải quyết một vấn đề rất thực tế:
Máy biết còn bao nhiêu lon?
Chúng ta sẽ xây dựng:
A1 → Coca → 5 lon
A2 → Pepsi → 2 lon
B1 → Sprite → 0 lon
Khi bán một lon:
5 → 4
Khi còn:
0
giao diện tự chuyển thành:
HẾT HÀNG
và hệ thống quản trị Laravel có thể biết máy nào đang cần nạp hàng.
Ở các bài trước, máy của chúng ta đã có:ESP32 điều khiển.Motor và cơ cấu trục xoắn.Encoder/Hall Sensor.Cảm biến phát hiện sản phẩm.State Machine.Giao ...
Ở bài 6, chúng ta đã xây dựng State Machine cho bộ não của máy.
Bây giờ đến phần mà khách hàng nhìn thấy đầu tiên:
Giao diện người dùng (UI/UX).
Một máy bán nước có phần cơ khí rất tốt nhưng giao diện khó sử dụng thì trải nghiệm vẫn rất tệ.
Mục tiêu của chúng ta là tạo một giao diện hiện đại, đơn giản, dễ bấm, lấy cảm hứng từ phong cách giao diện Windows/Material nhưng không sao chép logo hay nhận diện thương hiệu.
Phiên bản mini có thể bắt đầu với:
Màn hình cảm ứng 3.5–4.3 inch.
ESP32.
Độ phân giải khoảng 480×272 hoặc 800×480.
Giao tiếp SPI hoặc RGB tùy loại màn hình.
Nếu muốn làm giao diện đẹp hơn, có thể chọn:
ESP32 + màn hình cảm ứng 4.3 inch + LVGL
LVGL là thư viện giao diện rất phù hợp cho các thiết bị nhúng.
Một lỗi phổ biến khi thiết kế máy bán hàng là đưa quá nhiều thông tin lên màn hình.
Ví dụ không nên:
┌───────────────────────────────┐
│ A1 Coca 10.000 │ A2 Pepsi... │
│ B1 Sprite ... │ B2 Fanta... │
│ C1 Water ... │ C2 Tea ... │
│ │
│ QR │ CASH │ CARD │ SETTINGS │
└───────────────────────────────┘
Khách nhìn vào sẽ mất thời gian.
Chúng ta nên thiết kế:
┌───────────────────────────────┐
│ CHỌN ĐỒ UỐNG │
│ │
│ ┌─────────┐ ┌─────────┐ │
│ │ 🥤 │ │ 🥤 │ │
│ │ Coca │ │ Pepsi │ │
│ │ 15.000đ │ │ 15.000đ │ │
│ └─────────┘ └─────────┘ │
│ │
│ ┌─────────┐ ┌─────────┐ │
│ │ 🥤 │ │ 🥤 │ │
│ │ Sprite │ │ Fanta │ │
│ │ 15.000đ │ │ 15.000đ │ │
│ └─────────┘ └─────────┘ │
└───────────────────────────────┘
Khách chỉ cần:
Nhìn → chọn → thanh toán → nhận hàng.
Điểm quan trọng là:
UI không được tách rời State Machine.
Mỗi trạng thái của máy tương ứng với một màn hình.
IDLE
↓
Màn hình chờ
SELECT
↓
Màn hình chọn sản phẩm
PAYMENT
↓
Màn hình thanh toán
DISPENSE
↓
Màn hình đang lấy hàng
CHECK
↓
Màn hình kiểm tra
SUCCESS
↓
Màn hình hoàn tất
ERROR
↓
Màn hình báo lỗi
Như vậy phần mềm rất dễ quản lý.
Khi không có người sử dụng:
┌──────────────────────────────┐
│ │
│ VENDING │
│ │
│ 🥤 DRINK BAR │
│ │
│ Chạm để chọn sản phẩm │
│ │
│ 10:30 │
└──────────────────────────────┘
Có thể thêm:
Logo máy.
Hình ảnh sản phẩm.
Nhiệt độ.
Khuyến mãi.
Wi-Fi/4G.
Đồng hồ.
Nhưng không nên nhồi quá nhiều thông tin.
Đây là màn hình quan trọng nhất.
Ví dụ:
┌──────────────────────────────┐
│ Chọn sản phẩm │
│ │
│ ┌────────┐ ┌────────┐ │
│ │ Coca │ │ Pepsi │ │
│ │ 15.000 │ │ 15.000 │ │
│ │ A1 │ │ A2 │ │
│ └────────┘ └────────┘ │
│ │
│ ┌────────┐ ┌────────┐ │
│ │ Sprite │ │ Fanta │ │
│ │ 15.000 │ │ 15.000 │ │
│ │ B1 │ │ B2 │ │
│ └────────┘ └────────┘ │
└──────────────────────────────┘
Mỗi sản phẩm nên hiển thị:
Hình ảnh.
Tên.
Giá.
Mã sản phẩm.
Trạng thái còn hàng.
Không nên để khách bấm vào sản phẩm rồi mới báo hết hàng.
Ngay trên UI:
┌───────────────┐
│ │
│ Coca │
│ │
│ HẾT HÀNG │
│ │
└───────────────┘
Nút phải chuyển sang trạng thái:
DISABLED
Không thể nhấn.
Khi khách chọn Coca:
┌──────────────────────────────┐
│ COCA │
│ │
│ 🥤 │
│ │
│ 15.000đ │
│ │
│ [ MUA NGAY ] │
│ │
│ [ QUAY LẠI ] │
└──────────────────────────────┘
Không nên bắt khách đi qua quá nhiều màn hình.
Mục tiêu:
Từ màn hình chính đến thanh toán trong 1–2 thao tác.
Ví dụ:
┌──────────────────────────────┐
│ THANH TOÁN │
│ │
│ Coca │
│ 15.000đ │
│ │
│ ┌───────────────┐ │
│ │ QR │ │
│ │ ██████ │ │
│ │ ██ ██ │ │
│ └───────────────┘ │
│ │
│ Quét mã để thanh toán │
│ │
│ [ HỦY GIAO DỊCH ] │
└──────────────────────────────┘
Sau này có thể thêm:
QR
NFC
Thẻ
Tiền mặt
Nhưng bản mini đầu tiên chỉ cần QR là đủ.
Không chuyển ngay sang màn hình khác.
Nên cho khách thấy:
┌──────────────────────────────┐
│ │
│ ✓ │
│ │
│ THANH TOÁN THÀNH CÔNG │
│ │
│ 15.000đ │
│ │
└──────────────────────────────┘
Sau khoảng thời gian rất ngắn:
PAYMENT
↓
DISPENSE
Đây là phần rất quan trọng.
Không nên để màn hình đứng yên.
┌──────────────────────────────┐
│ │
│ ĐANG LẤY HÀNG │
│ │
│ ↻ │
│ │
│ Vui lòng chờ... │
│ │
└──────────────────────────────┘
Có thể hiển thị animation:
Motor
↻
↻
↻
Trong lúc đó ESP32 thực hiện:
Motor ON
↓
Encoder đếm
↓
Đủ vị trí
↓
Motor OFF
Sau khi motor dừng:
DISPENSE
↓
CHECK
↓
IR Sensor
Nếu cảm biến phát hiện lon:
SUCCESS
Nếu không:
ERROR
UI lúc này có thể hiển thị:
✓ Đã giao sản phẩm
Vui lòng lấy hàng.
Không nên sử dụng những thông báo kỹ thuật như:
ERROR CODE: MOTOR_04
Khách hàng không hiểu.
Thay bằng:
┌──────────────────────────────┐
│ ! │
│ │
│ KHÔNG THỂ LẤY HÀNG │
│ │
│ Vui lòng thử lại hoặc liên │
│ hệ nhân viên hỗ trợ. │
│ │
│ [ TRỞ VỀ ] │
└──────────────────────────────┘
Mã lỗi kỹ thuật chỉ dành cho màn hình quản trị.
Với concept máy mà chúng ta đang thiết kế, có thể sử dụng:
Background
#F5F7FA
Primary
#2563EB
Success
#16A34A
Warning
#F59E0B
Danger
#DC2626
Text
#111827
Không cần sử dụng quá nhiều màu.
Quy tắc đơn giản:
1 màu chính + 1 màu cảnh báo + 1 màu thành công.
Nút cảm ứng phải đủ lớn.
Không nên:
[ OK ]
quá nhỏ.
Nên:
┌──────────────────────┐
│ MUA NGAY │
└──────────────────────┘
Khách đứng trước máy có thể:
tay ướt
ánh sáng mạnh
thao tác nhanh
Vì vậy vùng cảm ứng phải đủ rộng.
Animation chỉ nên giúp khách hiểu:
Đang xử lý
Đang thanh toán
Đang nhả hàng
Hoàn tất
Không nên:
hiệu ứng bay quá nhiều
chuyển cảnh quá lâu
animation nặng
video nền
Máy bán hàng cần:
Nhanh hơn là đẹp.
Chúng ta có thể tổ chức phần mềm:
UI
│
├── HomeScreen
├── ProductScreen
├── PaymentScreen
├── DispenseScreen
├── SuccessScreen
└── ErrorScreen
Trong khi phần điều khiển:
Core
│
├── VendingMachine
├── MotorController
├── SensorController
├── PaymentController
└── Inventory
Hai phần này giao tiếp với nhau.
UI
│
▼
State Machine
│
┌────────┼────────┐
▼ ▼ ▼
Motor Sensor Payment
Đây là cách tổ chức tốt hơn rất nhiều so với việc nhét toàn bộ chương trình vào loop().
Ở phiên bản đầu tiên, chúng ta có thể dùng LVGL để xây dựng UI.
Kiến trúc:
ESP32
│
├── LVGL
│ │
│ ├── Button
│ ├── Label
│ ├── Image
│ ├── Screen
│ └── Animation
│
├── Touch Driver
│
└── Vending Controller
LVGL xử lý giao diện.
ESP32 xử lý logic máy.
Một ví dụ đơn giản với LVGL:
lv_obj_t *btn;
btn = lv_button_create(lv_screen_active());
lv_obj_set_size(btn, 200, 70);
lv_obj_center(btn);
lv_obj_t *label = lv_label_create(btn);
lv_label_set_text(label, "MUA NGAY");
lv_obj_center(label);
Khi khách chạm vào nút, chương trình có thể chuyển State Machine:
currentState = SELECT;
Hoặc nếu đang ở màn hình chọn sản phẩm:
selectedProduct = 0;
currentState = PAYMENT;
Đây là nguyên tắc rất quan trọng.
Không nên:
Button
↓
Motor
Mà phải:
Button
↓
UI Event
↓
State Machine
↓
Motor Controller
↓
Motor
Như vậy sau này:
màn hình bị thay đổi
thêm nút vật lý
thêm app điện thoại
thêm Web Admin
thì Motor Controller vẫn giữ nguyên.
Đến đây, hệ thống của chúng ta đã có kiến trúc:
TOUCH SCREEN
│
▼
UI/LVGL
│
▼
STATE MACHINE
│
┌──────────────┼──────────────┐
│ │ │
▼ ▼ ▼
PAYMENT MOTOR SENSOR
│ │ │
▼ ▼ ▼
QR/API DRIVER IR/ENCODER
│
▼
MOTOR
│
▼
SPIRAL
│
▼
🥤
Đây chính là lúc dự án bắt đầu giống một sản phẩm thực tế, thay vì chỉ là một mạch ESP32 điều khiển motor.
Trong bài này chưa cần làm toàn bộ giao diện.
Hãy làm theo thứ tự:
Tạo màn hình:
CHỌN SẢN PHẨM
Tạo 4 nút:
A1 A2
B1 B2
Khi nhấn A1:
selectedProduct = A1;
Chuyển sang:
PAYMENT
Giả lập thanh toán thành công bằng một nút:
THANH TOÁN
Chuyển:
PAYMENT
↓
DISPENSE
Sau đó mới kết nối với motor thật.
Sau bài 7, chúng ta đã thiết kế được giao diện người dùng hoàn chỉnh về mặt luồng:
┌──────────┐
│ IDLE │
└────┬─────┘
↓
┌──────────┐
│ SELECT │
└────┬─────┘
↓
┌──────────┐
│ PAYMENT │
└────┬─────┘
↓
┌──────────┐
│ DISPENSE │
└────┬─────┘
↓
┌──────────┐
│ CHECK │
└────┬─────┘
↓
┌──────────┐
│ SUCCESS │
└────┬─────┘
↓
IDLE
Quan trọng nhất là chúng ta đã thống nhất một nguyên tắc:
UI chỉ nhận thao tác của người dùng. State Machine quyết định máy phải làm gì. Controller điều khiển phần cứng.
Đây sẽ là nền tảng để các bài sau không biến thành một đống code Arduino khó bảo trì.
Ở bài tiếp theo, chúng ta sẽ đưa thanh toán thật vào máy: tạo mã QR theo sản phẩm, nhận trạng thái thanh toán qua Internet, xác nhận giao dịch rồi mới cho phép ESP32 kích hoạt cơ cấu nhả lon.
Ở bài 6, chúng ta đã xây dựng State Machine cho bộ não của máy.Bây giờ đến phần mà khách hàng nhìn thấy đầu tiên:Giao diện người dùng (UI/UX).Một máy ...
Laravel không có một thư mục helpers mặc định. Nếu bạn muốn tự viết sanitizeHtml() thì nên tự tạo một helper riêng.
Với project của bạn, mình khuyên:
app/
└── Helpers/
└── helpers.php
app/Helpers/helpers.php
Ví dụ:
<?php
if (! function_exists('sanitizeHtml')) {
function sanitizeHtml(string $html): string
{
// code sanitize của bạn
return $html;
}
}
Mở:
composer.json
Tìm:
"autoload": {
"psr-4": {
"App\\": "app/",
...
}
}
thêm files:
"autoload": {
"files": [
"app/Helpers/helpers.php"
],
"psr-4": {
"App\\": "app/"
}
}
Nếu autoload của bạn đã có files thì chỉ thêm đường dẫn vào mảng đó.
composer dump-autoload
Sau đó có thể gọi ở bất kỳ Controller, Model, Service...:
$content = sanitizeHtml($request->content);
hoặc trực tiếp:
'content' => sanitizeHtml($request->content),
app/
├── Helpers/
│ └── helpers.php
│
├── Http/
│ └── Controllers/
│
├── Models/
│
└── ...
Đây là cách khá gọn cho những function global, dùng nhiều nơi, như:
sanitizeHtml()
formatMoney()
slugify()
formatDate()
Còn nếu function chỉ phục vụ riêng cho Post, thì tốt hơn đưa nó vào Service/Action riêng thay vì biến mọi thứ thành global helper.
Laravel không có một thư mục helpers mặc định. Nếu bạn muốn tự viết sanitizeHtml() thì nên tự tạo một helper riêng.Với project của bạn, mình khuyên:ap...
Ở bài trước, chúng ta đã có:
ESP32
Động cơ
Driver
Encoder
Cảm biến rơi sản phẩm
Nếu tiếp tục viết chương trình theo kiểu:
if(...)
{
...
}
if(...)
{
...
}
if(...)
{
...
}
Thì chỉ sau vài ngày, chương trình sẽ trở nên rất khó đọc và khó bảo trì.
Các máy bán hàng thương mại không lập trình như vậy.
Họ sử dụng một mô hình gọi là State Machine (Máy trạng thái).
Sau bài này bạn sẽ:
Hiểu State Machine là gì.
Biết cách chia chương trình thành từng trạng thái.
Thiết kế phần mềm giống máy bán hàng thương mại.
Viết bộ khung chương trình đầu tiên.
Hãy tưởng tượng một chiếc thang máy.
Nó không thể:
mở cửa
chạy
đóng cửa
cùng một lúc.
Nó luôn ở một trạng thái xác định.
Ví dụ:
Đang chờ
↓
Đóng cửa
↓
Di chuyển
↓
Mở cửa
↓
Chờ
Máy bán nước cũng vậy.
Phiên bản mini của chúng ta sẽ có khoảng 8 trạng thái.
IDLE
↓
SELECT
↓
PAYMENT
↓
DISPENSE
↓
CHECK
↓
SUCCESS
↓
IDLE
Nếu có lỗi:
CHECK
↓
ERROR
↓
IDLE
Máy đang chờ.
Màn hình hiển thị:
Xin chào
Mời chọn sản phẩm
Động cơ:
Không chạy.
Khách chọn:
A1
B2
C3
D4
ESP32 lưu lại:
selectedProduct = A1;
Máy chờ thanh toán.
Có thể:
QR
NFC
Tiền mặt
Nếu thanh toán thành công:
↓
DISPENSE
Motor bắt đầu quay.
Motor
↓
Spiral
↓
Lon tiến lên
Encoder bắt đầu đếm.
Motor đã dừng.
ESP32 chờ:
IR Sensor
↓
Có lon chưa?
Nếu:
Có.
↓
SUCCESS
Nếu:
Không.
↓
ERROR
Hiển thị:
Cảm ơn!
Chúc ngon miệng.
Sau vài giây:
↓
IDLE
Ví dụ:
Sản phẩm bị kẹt.
Hoặc:
Hết hàng.
Máy sẽ:
Báo lỗi
Hoàn tiền (nếu có)
Gửi log
+------+
|IDLE |
+------+
|
v
+-------------+
|SELECT |
+-------------+
|
v
+-------------+
|PAYMENT |
+-------------+
|
v
+-------------+
|DISPENSE |
+-------------+
|
v
+-------------+
|CHECK |
+-------------+
| |
Có lon Không
| |
v v
+---------+ +-------+
|SUCCESS | |ERROR |
+---------+ +-------+
\ /
\ /
v v
+------+
|IDLE |
+------+
Trong Arduino:
enum State
{
IDLE,
SELECT,
PAYMENT,
DISPENSE,
CHECK,
SUCCESS,
ERROR_STATE
};
State currentState = IDLE;
void loop()
{
switch(currentState)
{
case IDLE:
break;
case SELECT:
break;
case PAYMENT:
break;
case DISPENSE:
break;
case CHECK:
break;
case SUCCESS:
break;
case ERROR_STATE:
break;
}
}
Đây chính là "bộ não" của chiếc máy.
Khách nhấn:
A2
ESP32:
currentState = SELECT;
Thanh toán thành công.
currentState = PAYMENT;
↓
currentState = DISPENSE;
↓
currentState = CHECK;
↓
currentState = SUCCESS;
↓
currentState = IDLE;
Thay vì:
delay(5000);
Ta dùng:
unsigned long timer;
Ví dụ:
if(millis()-timer>5000)
{
currentState = IDLE;
}
Ưu điểm:
Máy vẫn:
đọc QR
đọc nút bấm
cập nhật màn hình
gửi WiFi
trong khi đang chờ.
Không nên viết:
main.cpp
3000 dòng
Hãy chia:
src
├── main.cpp
├── motor.cpp
├── motor.h
├── payment.cpp
├── payment.h
├── display.cpp
├── display.h
├── sensor.cpp
├── sensor.h
├── vending.cpp
└── vending.h
Sau này dự án lớn hơn vẫn rất dễ quản lý.
Mỗi giao dịch nên lưu:
20:31:20
A1
QR
SUCCESS
Hoặc:
20:35:02
B3
ERROR
NO PRODUCT
Nhật ký giúp:
tìm lỗi
thống kê doanh thu
kiểm tra bảo trì
Khi đã có State Machine, việc thêm tính năng sẽ rất đơn giản:
IDLE
↓
UPDATE
↓
DOWNLOAD
↓
RESTART
Hoặc:
IDLE
↓
ADMIN MODE
↓
TEST MOTOR
↓
TEST SENSOR
Bạn không cần viết lại toàn bộ chương trình.
Người dùng
│
▼
Màn hình cảm ứng
│
▼
State Machine
│
┌─────────┼─────────┐
▼ ▼ ▼
Payment Motor Sensor
│ │ │
└─────────┼─────────┘
▼
Display
│
▼
WiFi / Cloud
Đây là mô hình thường gặp trong các thiết bị IoT hiện đại, giúp mỗi thành phần có một nhiệm vụ rõ ràng và dễ bảo trì.
Tạo enum State với 7 trạng thái như trong bài.
Viết switch(currentState) trong loop().
Mỗi trạng thái chỉ cần Serial.println() tên của nó để kiểm tra luồng hoạt động.
Thử mô phỏng một giao dịch hoàn chỉnh:
IDLE → SELECT → PAYMENT → DISPENSE → CHECK → SUCCESS → IDLE.
Thử mô phỏng trường hợp lỗi:
IDLE → SELECT → PAYMENT → DISPENSE → CHECK → ERROR → IDLE.
Đến bài này, bạn đã chuyển từ việc điều khiển từng linh kiện riêng lẻ sang thiết kế kiến trúc phần mềm cho cả hệ thống.
Bạn đã biết:
State Machine là gì và vì sao nó được dùng trong máy bán hàng tự động.
Cách chia chương trình thành các trạng thái rõ ràng.
Tổ chức mã nguồn theo từng module.
Sử dụng millis() thay cho delay() để hệ thống luôn phản hồi tốt.
🥤 Bài 7 — Thiết kế giao diện người dùng (UI/UX) trên màn hình cảm ứng
Chúng ta sẽ xây dựng một giao diện hiện đại theo phong cách Windows/Material Design, với màn hình chọn sản phẩm, giỏ hàng, trạng thái thanh toán, hiệu ứng chuyển cảnh và các nguyên tắc UX giúp khách hàng thao tác nhanh, trực quan và hạn chế nhầm lẫn khi sử dụng máy bán nước tự động.
Ở bài trước, chúng ta đã có:ESP32Động cơDriverEncoderCảm biến rơi sản phẩmNếu tiếp tục viết chương trình theo kiểu:if(...) { ... } if(...) { ...
Sau khi hoàn thành module Categories, chúng ta sẽ xây dựng chức năng quan trọng nhất của Blog CMS: quản lý bài viết (Posts).
Khác với Category chỉ có vài trường dữ liệu, Post sẽ liên kết với nhiều bảng khác nhau như Category, User, đồng thời hỗ trợ upload hình ảnh, kiểm tra dữ liệu đầu vào và hiển thị danh sách bài viết chuyên nghiệp.
Đây cũng là module có số lượng kiến thức nhiều nhất trong toàn bộ dự án.
Sau bài này bạn sẽ thực hiện được:
CRUD bài viết
Upload ảnh đại diện
Chọn Category
Gán tác giả (User)
Validation dữ liệu
Hiển thị Relationship
Tự động tạo slug
Phân trang dữ liệu
Danh sách bài viết sẽ hiển thị như sau:
| Ảnh | Tiêu đề | Category | Tác giả | Ngày đăng | Thao tác |
|---|---|---|---|---|---|
| ✔ | Laravel 12 mới có gì? | Laravel | Admin | 07/08/2026 | Edit / Delete |
Bảng posts
| Field | Kiểu |
|---|---|
| id | bigint |
| user_id | foreignId |
| category_id | foreignId |
| title | string |
| slug | string |
| image | string |
| excerpt | text |
| content | longText |
| created_at | timestamp |
Quan hệ
User
│
├──────< Posts >────── Category
Một User có nhiều Post.
Một Category có nhiều Post.
Một Post chỉ thuộc một User và một Category.
Chú ý:
create_posts_table
Đưa luôn cấu trúc hoàn chỉnh vào migration tạo posts:
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')
->constrained()
->cascadeOnDelete();
$table->foreignId('category_id')
->nullable()
->constrained()
->nullOnDelete();
$table->string('title');
$table->string('slug')
->unique();
$table->string('image')
->nullable();
$table->text('excerpt')
->nullable();
$table->longText('content');
$table->enum('status', [
'draft',
'published',
'hidden',
'scheduled'
])->default('draft');
$table->timestamp('published_at')->nullable();
$table->timestamps();
});
Như vậy bạn không cần 3 migration riêng để:
category_id
image
slug
excerpt
nữa, nếu đây vẫn là database đang phát triển và chưa cần bảo toàn dữ liệu production.
Nếu bạn đã có các migration kiểu:
add_category_id_to_posts_table
add_image_to_posts_table
add_slug_and_excerpt_to_posts_table
thì có thể xóa chúng sau khi đã đưa cấu trúc cuối cùng vào migration create_posts_table.
Mục tiêu là migration tạo bảng posts ngay từ đầu đã hoàn chỉnh.
app/Models/Post.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
protected $fillable = [
'user_id',
'category_id',
'title',
'slug',
'image',
'excerpt',
'content',
'status',
'published_at',
];
protected $casts = [
'published_at' => 'datetime',
];
}
<?php
namespace Database\Factories;
use App\Models\Model;
use App\Models\User;
use App\Models\Category;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Model>
*/
class PostFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$status = fake()->randomElement([
'draft',
'published',
'hidden',
'scheduled',
]);
return [
// Định nghĩa dữ liệu mẫu
'title' => fake()->sentence(),
'slug' => fake()->unique()->slug(),
'image' => fake()->imageUrl(1200, 800),
'excerpt' => fake()->paragraph(),
'content' => fake()->paragraphs(5, true),
'user_id' => fn () =>
User::query()->inRandomOrder()->value('id'),
'category_id' => fn () =>
Category::query()->inRandomOrder()->value('id'),
'status' => $status,
'published_at' => match ($status) {
'published' =>
fake()->dateTimeBetween('-1 year', 'now'),
'scheduled' =>
fake()->dateTimeBetween('now', '+1 month'),
default => null,
},
];
}
}
Chú ý: phải tạo bảng chứa khóa ngoại trước nếu không sẽ báo lỗi.
thứ tự migration nên là:
1. create_countries_table
2. create_users_table
3. create_categories_table
4. create_posts_table
Sau đó chạy:
php artisan migrate:fresh --seed
public function posts()
{
return $this->hasMany(Post::class);
}
public function posts()
{
return $this->hasMany(Post::class);
}
public function category()
{
return $this->belongsTo(Category::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
Đây là ba relationship được sử dụng xuyên suốt dự án.
Tạo controller
php artisan make:controller PostController --resource
Đăng ký route
Route::resource('posts', PostController::class);
Laravel sẽ tạo sẵn:
index
create
store
show
edit
update
destroy
Controller
public function index()
{
//dd(Auth::user());
//lấy sẵn user để sau này chặn ngay từ view chỉ có admin được xem all posts
$user = Auth::user();
$posts = Post::with(['category','user'])
->latest()
//->paginate(10); chỗ này dùng dataTables quản lý rồi nên k cần
->get();
return view('posts.index', compact('posts'));
}
Điểm quan trọng
with()
Laravel sẽ eager loading.
Không xảy ra lỗi N+1 Query.
Chú ý: nhớ kiểm tra route list trước coi đủ 7 món hay không.
php artisan optimize:clear
Kiểm tra:
php artisan route:list --name=posts
x-app-layout đã có sẵn thư viên datatables để xử lý gọn phần bảngviews/layouts/app.blade.php đã nạp đủ thư viện
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name', 'Laravel') }}</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet">
<link rel="stylesheet"
href="https://cdn.datatables.net/2.3.4/css/dataTables.dataTables.min.css">
<!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="font-sans antialiased">
<div class="min-h-screen bg-gray-100">
@include('layouts.navigation')
<!-- Page Heading -->
@isset($header)
<header class="bg-white shadow">
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
{{ $header }}
</div>
</header>
@endisset
<!-- Page Content -->
<main>
{{ $slot }}
</main>
</div>
</body>
</html>
File resources/js/app.js đã import đủ và xử lý JavaScript.
import $ from 'jquery';
window.$ = $;
window.jQuery = $;
import DataTable from 'datatables.net';
import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();
//cáu hình cho table posts index blade
const table = document.querySelector('#postsTable');
if (table) {
new DataTable(table, {
language: {
search: "🔍 Tìm kiếm:",
lengthMenu: "Hiển thị _MENU_ dòng",
info: "Hiển thị _START_ đến _END_ trong tổng số _TOTAL_ bài viết",
paginate: {
first: "Đầu",
last: "Cuối",
next: "Sau",
previous: "Trước"
},
zeroRecords: "Không tìm thấy dữ liệu",
emptyTable: "Chưa có dữ liệu"
}
});
}
posts.index<x-app-layout>
<div class="py-6">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold text-gray-800">
Quản lý bài viết
</h2>
<p class="mt-1 text-sm text-gray-500">
Danh sách tất cả bài viết trong hệ thống
</p>
</div>
<a href="{{ route('posts.create') }}"
class="inline-flex items-center rounded-lg bg-indigo-600 px-4 py-2.5
text-sm font-semibold text-white shadow-sm
hover:bg-indigo-700">
+ Thêm bài viết
</a>
</div>
<!-- Card -->
<div class="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-200">
<!-- Card Header -->
<div class="border-b border-gray-200 px-6 py-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-800">
Danh sách bài viết
</h3>
<p class="text-sm text-gray-500">
Tổng cộng {{ $posts->total() }} bài viết
</p>
</div>
</div>
</div>
{{-- Alert --}}
<x-alert />
<!-- Table -->
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Tiêu đề
</th>
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Ngày
</th>
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Tác giả
</th>
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Danh mục
</th>
<th class="px-6 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500">
Thao tác
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
@forelse($posts as $post)
<tr class="hover:bg-gray-50">
{{-- TITLE --}}
<td class="px-6 py-4">
<a href="{{ route('posts.show', $post) }}"
class="font-semibold text-gray-800 hover:text-indigo-600">
{{ $post->title }}
</a>
</td>
{{-- DATE --}}
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
{{ $post->created_at->format('d/m/Y') }}
</td>
{{-- AUTHOR --}}
<td class="whitespace-nowrap px-6 py-4">
@if($post->user)
<span class="text-sm font-medium text-gray-700">
{{ $post->user->name }}
</span>
@else
<span class="text-sm text-gray-400">
—
</span>
@endif
</td>
{{-- CATEGORY --}}
<td class="whitespace-nowrap px-6 py-4">
@if($post->category)
<span class="inline-flex rounded-full
bg-blue-100 px-3 py-1
text-xs font-medium text-blue-700">
{{ $post->category->name }}
</span>
@else
<span class="text-sm text-gray-400">
—
</span>
@endif
</td>
{{-- ACTIONS --}}
<td class="whitespace-nowrap px-6 py-4">
<div class="flex justify-end gap-2">
{{-- VIEW --}}
<a href="{{ route('posts.show', $post) }}"
class="rounded-lg bg-gray-100 px-3 py-2
text-sm font-medium text-gray-700
hover:bg-gray-200">
Xem
</a>
{{-- EDIT --}}
<a href="{{ route('posts.edit', $post) }}"
class="rounded-lg bg-blue-100 px-3 py-2
text-sm font-medium text-blue-700
hover:bg-blue-200">
Sửa
</a>
{{-- DELETE --}}
<form action="{{ route('posts.destroy', $post) }}"
method="POST"
onsubmit="return confirm('Bạn có chắc muốn xóa bài viết này?');">
@csrf
@method('DELETE')
<button type="submit"
class="rounded-lg bg-red-100 px-3 py-2
text-sm font-medium text-red-700
hover:bg-red-200">
Xóa
</button>
</form>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="5"
class="px-6 py-12 text-center text-sm text-gray-400">
Chưa có bài viết nào.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<!-- Pagination -->
@if($posts->hasPages())
<div class="border-t border-gray-200 bg-white px-6 py-4">
{{ $posts->links() }}
</div>
@endif
</div>
</div>
</div>
</x-app-layout>Trong Blade
{{ $post->category->name }}
{{ $post->user->name }}
Không cần viết Query JOIN.
Đó chính là sức mạnh của Eloquent ORM.
Form sẽ có:
Tiêu đề
Slug
Category
Hình ảnh
Mô tả ngắn
Nội dung
Category
<select name="category_id">
@foreach($categories as $category)
<option value="{{ $category->id }}">
{{ $category->name }}
</option>
@endforeach
</select>
Laravel sẽ lấy toàn bộ Category để người dùng lựa chọn.
Bước 1: sửa hàm store trong PostController
public function store(Request $request)
{
$request->validate(
[
'title' => 'required|max:255',
'slug' => [
'required',
'max:255',
'unique:posts,slug',
],
'excerpt' => 'nullable',
'content' => 'required|string',
'category_id' => [
'nullable',
'exists:categories,id',
],
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
'image' => [
'nullable',
'image',
'mimes:jpg,jpeg,png,webp',
'max:2048',
],
'status' => [
'required',
'in:draft,published,hidden,scheduled',
],
'published_at' => 'nullable|date',
],
[
'title.required' => 'Vui lòng nhập tiêu đề.',
'title.max' => 'Tiêu đề tối đa 255 ký tự.',
'slug.required' => 'Vui lòng nhập slug.',
'slug.unique' => 'Slug này đã tồn tại.',
'slug.max' => 'Slug tối đa 255 ký tự.',
'content.required' => 'Vui lòng nhập nội dung.',
'content.min' => 'Nội dung tối thiểu 20 ký tự.',
'category_id.exists' => 'Danh mục không tồn tại.',
'tags.array' => 'Tags không hợp lệ.',
'tags.*.exists' => 'Tag không tồn tại.',
'image.image' => 'File tải lên phải là hình ảnh.',
'image.mimes' => 'Ảnh phải có định dạng jpg, jpeg, png hoặc webp.',
'image.max' => 'Ảnh tối đa 2MB.',
'status.required' => 'Vui lòng chọn trạng thái.',
'status.in' => 'Trạng thái không hợp lệ.',
'published_at.date' => 'Ngày xuất bản không hợp lệ.',
]
);
/*
|--------------------------------------------------------------------------
| Lưu ảnh
|--------------------------------------------------------------------------
*/
$image = null;
if ($request->hasFile('image')) {
$image = $request
->file('image')
->store(
'posts',
'public'
);
}
/*
|--------------------------------------------------------------------------
| Published At
|--------------------------------------------------------------------------
*/
$publishedAt = $request->published_at;
// Nếu chọn Published nhưng không nhập ngày
// thì lấy thời điểm hiện tại
if (
$request->status === 'published'
&& empty($publishedAt)
) {
$publishedAt = now();
}
/*
|--------------------------------------------------------------------------
| SANITIZE CONTENT
|--------------------------------------------------------------------------
*/
$content = sanitizeHtml($request->content);
/*
|--------------------------------------------------------------------------
| KIỂM TRA NỘI DUNG THỰC
|--------------------------------------------------------------------------
*/
//dd($content);
if (mb_strlen(trim(strip_tags($content))) < 20) {
return back()
->withInput()
->withErrors([
'content' => 'Nội dung phải có ít nhất 20 ký tự.',
]);
}
/*
|--------------------------------------------------------------------------
| Tạo Post
|--------------------------------------------------------------------------
*/
$post = Post::create([
'user_id' => auth()->id(),
'category_id' => $request->category_id,
'title' => $request->title,
'slug' => $request->slug,
'image' => $image,
'excerpt' => $request->excerpt,
// Lưu HTML đã sanitize
'content' => $content,
'status' => $request->status,
'published_at' => $publishedAt,
]);
/*
|--------------------------------------------------------------------------
| Lưu Tags
|--------------------------------------------------------------------------
*/
$post->tags()->sync(
$request->tags ?? []
);
/*
|--------------------------------------------------------------------------
| Redirect
|--------------------------------------------------------------------------
*/
return redirect()
->route('posts.show', $post)
->with(
'success',
'Bài viết đã được tạo thành công.'
);
}
//END store function
Create Blade Form:
<x-app-layout>
<div class="max-w-3xl mx-auto py-8">
<x-ui.card
title="Thêm Bài Viết"
description="Nhập thông tin bài viết">
<form
action="{{ route('posts.store') }}"
method="POST"
enctype="multipart/form-data">
@csrf
{{-- TITLE --}}
<x-ui.input
label="Tiêu đề"
name="title"
/>
{{-- SLUG --}}
<x-ui.input-slug
label="Slug"
name="slug"
source="title"
/>
{{-- EXCERPT --}}
<x-ui.textarea
label="Mô tả ngắn"
name="excerpt"
rows="3"
/>
{{-- CONTENT --}}
<x-ui.editor
label="Nội dung"
name="content"
height="500px"
/>
{{-- CATEGORY --}}
<x-ui.select
label="Danh mục"
name="category_id"
:options="$categories"
/>
{{-- TAGS --}}
<x-ui.select
label="Tags"
name="tags"
:options="$tags"
multiple
/>
{{-- IMAGE --}}
<x-ui.file-input
label="Ảnh đại diện"
name="image"
/>
{{-- STATUS --}}
<x-ui.select
label="Trạng thái"
name="status"
:options="[
'draft' => 'Nháp',
'published' => 'Xuất bản',
'hidden' => 'Ẩn',
'scheduled' => 'Lên lịch',
]"
/>
{{-- PUBLISHED AT --}}
<x-ui.input
label="Ngày xuất bản"
name="published_at"
type="datetime-local"
/>
<div class="mt-6">
<x-ui.button>
Lưu bài viết
</x-ui.button>
</div>
</form>
</x-ui.card>
<!--card chứa form-->
</div>
</x-app-layout>
abc
$request->validate([
'title'=>'required|max:255',
'slug'=>'required|unique:posts',
'category_id'=>'required|exists:categories,id',
'image'=>'nullable|image|max:2048',
'content'=>'required'
]);
Laravel sẽ kiểm tra:
tiêu đề bắt buộc
slug không được trùng
category phải tồn tại
chỉ nhận ảnh
dung lượng tối đa
nội dung không được rỗng
Form
enctype="multipart/form-data"
Controller
$image = null;
if($request->hasFile('image')){
$image = $request->file('image')
->store('posts','public');
}
Kết quả
storage/app/public/posts/
abc123.jpg
xyz456.png
Laravel sẽ tự sinh tên file ngẫu nhiên để tránh trùng.
Post::create([
'user_id'=>auth()->id(),
'category_id'=>$request->category_id,
'title'=>$request->title,
'slug'=>$request->slug,
'image'=>$image,
'excerpt'=>$request->excerpt,
'content'=>$request->content
]);
Tác giả sẽ được lấy từ
auth()->id()
Người dùng không thể giả mạo tác giả bài viết.
Blade
<img src="{{ asset('storage/'.$post->image) }}">
Đừng quên tạo symbolic link
php artisan storage:link
Sau khi chạy lệnh này, Laravel sẽ tạo
public/storage
trỏ tới
storage/app/public
Nếu người dùng chọn ảnh mới
Storage::disk('public')->delete($post->image);
sau đó upload lại.
Nếu không chọn ảnh
Laravel vẫn giữ ảnh cũ.
Đây là cách xử lý được sử dụng trong hầu hết các CMS hiện nay.
PostController
/**
* Show the form for editing the specified resource.
*/
public function edit(Post $post)
{
$categories = Category::orderBy('name')->get();
return view('posts.edit', compact('post', 'categories'));
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Post $post)
{
$data = $request->validate([
'title' => 'required|string|max:255',
'slug' => 'required|string|max:255|unique:posts,slug,' . $post->id,
'category_id' => 'nullable|exists:categories,id',
'image' => 'nullable|image|max:2048',
'excerpt' => 'nullable|string',
'content' => 'required|string',
'status' => 'required|in:draft,published,hidden,scheduled',
'published_at' => 'nullable|date',
]);
if ($request->hasFile('image')) {
if (
$post->image &&
Storage::disk('public')->exists($post->image)
) {
Storage::disk('public')->delete($post->image);
}
$data['image'] = $request
->file('image')
->store('posts', 'public');
}
$post->update($data);
return redirect()
->route('posts.index')
->with('success', 'Cập nhật bài viết thành công.');
}
posts/edit.blade.php
<x-app-layout>
<div class="py-6">
<div class="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8">
<div class="overflow-hidden rounded-xl bg-white shadow-sm">
{{-- HEADER --}}
<div class="border-b p-5">
<h2 class="text-xl font-bold text-gray-800">
Chỉnh sửa bài viết
</h2>
<p class="mt-1 text-sm text-gray-500">
Cập nhật nội dung bài viết
</p>
</div>
{{-- ALERT --}}
<x-alert />
{{-- VALIDATION ERRORS --}}
@if ($errors->any())
<div class="mx-5 mt-5 rounded-lg bg-red-50 p-4 text-sm text-red-700">
<ul class="list-inside list-disc">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
{{-- FORM --}}
<form action="{{ route('posts.update', $post) }}"
method="POST"
enctype="multipart/form-data">
@csrf
@method('PUT')
<div class="space-y-6 p-5">
{{-- TITLE --}}
<div>
<label for="title"
class="mb-2 block text-sm font-medium text-gray-700">
Tiêu đề
</label>
<input type="text"
id="title"
name="title"
value="{{ old('title', $post->title) }}"
class="w-full rounded-lg border-gray-300
focus:border-indigo-500 focus:ring-indigo-500"
placeholder="Nhập tiêu đề bài viết">
@error('title')
<p class="mt-1 text-sm text-red-600">
{{ $message }}
</p>
@enderror
</div>
{{-- SLUG --}}
<div>
<label for="slug"
class="mb-2 block text-sm font-medium text-gray-700">
Slug
</label>
<input type="text"
id="slug"
name="slug"
value="{{ old('slug', $post->slug) }}"
class="w-full rounded-lg border-gray-300
focus:border-indigo-500 focus:ring-indigo-500"
placeholder="duong-dan-bai-viet">
<p class="mt-1 text-xs text-gray-400">
Ví dụ: bai-viet-dau-tien
</p>
@error('slug')
<p class="mt-1 text-sm text-red-600">
{{ $message }}
</p>
@enderror
</div>
{{-- CATEGORY + STATUS --}}
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
{{-- CATEGORY --}}
<div>
<label for="category_id"
class="mb-2 block text-sm font-medium text-gray-700">
Danh mục
</label>
<select id="category_id"
name="category_id"
class="w-full rounded-lg border-gray-300
focus:border-indigo-500 focus:ring-indigo-500">
<option value="">
-- Không có danh mục --
</option>
@foreach ($categories as $category)
<option value="{{ $category->id }}"
@selected(
old(
'category_id',
$post->category_id
) == $category->id
)>
{{ $category->name }}
</option>
@endforeach
</select>
@error('category_id')
<p class="mt-1 text-sm text-red-600">
{{ $message }}
</p>
@enderror
</div>
{{-- STATUS --}}
<div>
<label for="status"
class="mb-2 block text-sm font-medium text-gray-700">
Trạng thái
</label>
<select id="status"
name="status"
class="w-full rounded-lg border-gray-300
focus:border-indigo-500 focus:ring-indigo-500">
<option value="draft"
@selected(old('status', $post->status) === 'draft')}>
Bản nháp
</option>
<option value="published"
@selected(old('status', $post->status) === 'published')}>
Đã xuất bản
</option>
<option value="hidden"
@selected(old('status', $post->status) === 'hidden')}>
Đã ẩn
</option>
<option value="scheduled"
@selected(old('status', $post->status) === 'scheduled')}>
Đã lên lịch
</option>
</select>
@error('status')
<p class="mt-1 text-sm text-red-600">
{{ $message }}
</p>
@enderror
</div>
</div>
{{-- IMAGE --}}
<div>
<label for="image"
class="mb-2 block text-sm font-medium text-gray-700">
Ảnh đại diện
</label>
@if ($post->image)
<div class="mb-3">
<img src="{{ asset('storage/' . $post->image) }}"
alt="{{ $post->title }}"
class="h-32 w-48 rounded-lg object-cover">
</div>
@endif
<input type="file"
id="image"
name="image"
accept="image/*"
class="block w-full rounded-lg border border-gray-300
bg-white text-sm text-gray-700">
<p class="mt-1 text-xs text-gray-400">
Chọn ảnh mới nếu muốn thay ảnh hiện tại.
</p>
@error('image')
<p class="mt-1 text-sm text-red-600">
{{ $message }}
</p>
@enderror
</div>
{{-- EXCERPT --}}
<div>
<label for="excerpt"
class="mb-2 block text-sm font-medium text-gray-700">
Mô tả ngắn
</label>
<textarea id="excerpt"
name="excerpt"
rows="3"
class="w-full rounded-lg border-gray-300
focus:border-indigo-500 focus:ring-indigo-500"
placeholder="Nhập mô tả ngắn cho bài viết...">{{ old('excerpt', $post->excerpt) }}</textarea>
@error('excerpt')
<p class="mt-1 text-sm text-red-600">
{{ $message }}
</p>
@enderror
</div>
{{-- CONTENT --}}
<div>
<label for="content"
class="mb-2 block text-sm font-medium text-gray-700">
Nội dung
</label>
<textarea id="content"
name="content"
rows="18"
class="w-full rounded-lg border-gray-300
font-mono text-sm
focus:border-indigo-500 focus:ring-indigo-500"
placeholder="Nhập nội dung bài viết...">{{ old('content', $post->content) }}</textarea>
@error('content')
<p class="mt-1 text-sm text-red-600">
{{ $message }}
</p>
@enderror
</div>
{{-- PUBLISHED AT --}}
<div>
<label for="published_at"
class="mb-2 block text-sm font-medium text-gray-700">
Ngày xuất bản
</label>
<input type="datetime-local"
id="published_at"
name="published_at"
value="{{ old(
'published_at',
$post->published_at
? $post->published_at->format('Y-m-d\TH:i')
: ''
) }}"
class="rounded-lg border-gray-300
focus:border-indigo-500 focus:ring-indigo-500">
<p class="mt-1 text-xs text-gray-400">
Có thể để trống nếu bài viết chưa xuất bản.
</p>
@error('published_at')
<p class="mt-1 text-sm text-red-600">
{{ $message }}
</p>
@enderror
</div>
</div>
{{-- FOOTER --}}
<div class="flex items-center justify-between border-t bg-gray-50 p-5">
<a href="{{ route('posts.index') }}"
class="rounded-lg bg-gray-200 px-4 py-2
text-sm font-medium text-gray-700
hover:bg-gray-300">
Hủy
</a>
<div class="flex gap-2">
<a href="{{ route('posts.show', $post) }}"
class="rounded-lg bg-gray-100 px-4 py-2
text-sm font-medium text-gray-700
hover:bg-gray-200">
Xem bài viết
</a>
<button type="submit"
class="rounded-lg bg-indigo-600 px-5 py-2
text-sm font-medium text-white
hover:bg-indigo-700">
Cập nhật bài viết
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</x-app-layout>
app/Policies/PostPolicy.php
public function update(User $user, Post $post): bool
{
//chỉ cho admin và user tạo ra post có quyền chỉnh sửa
return $user->role === 'admin'
|| $user->id === $post->user_id;
}
Sửa hàm destroy trong PostController
public function destroy(string $id)
{
//
$post = Post::findOrFail($id);
$post->delete();
return redirect()
->route('posts.index')
->with('success', 'Xóa bài viết thành công.');
}
Khi xóa
if (!empty($post->image)) {
Storage::disk('public')->delete($post->image);
}
$post->delete();
Không nên để file ảnh bị "mồ côi" trong thư mục Storage.
Danh sách nên hiển thị
Thumbnail
Tiêu đề
Danh mục
Tác giả
Ngày tạo
Trạng thái
Nút Edit
Nút Delete
Có thể kết hợp:
Tailwind CSS
DataTables.net
Badge màu
Icon Heroicons
Xác nhận trước khi xóa
Đây cũng là giao diện mà chúng ta sẽ sử dụng xuyên suốt các module còn lại.
Nếu sau khi chỉnh sửa giao diện mà k thay đổi gì phải chạy npm run build
Sau bài học này, Blog CMS đã có module quản lý bài viết hoàn chỉnh.
✔ Thêm bài viết
✔ Sửa bài viết
✔ Xóa bài viết
✔ Upload ảnh
✔ Relationship User
✔ Relationship Category
✔ Validation
✔ Hiển thị ảnh
✔ Phân trang
✔ Quản lý tác giả
Đây là một module CRUD thực tế mà bạn sẽ gặp trong hầu hết các hệ thống quản trị nội dung sử dụng Laravel.
Trong bài học này chúng ta đã kết hợp rất nhiều kiến thức đã học trước đó:
Resource Controller để xây dựng CRUD nhanh chóng.
Eloquent Relationship để liên kết Post với Category và User.
Validation để đảm bảo dữ liệu hợp lệ.
Storage để quản lý hình ảnh.
Authentication để tự động gán tác giả cho bài viết.
Pagination giúp danh sách bài viết dễ theo dõi khi số lượng tăng lên.
Đến thời điểm này, dự án Blog CMS đã gần hoàn chỉnh và có cấu trúc tương tự nhiều hệ thống quản trị nội dung được sử dụng trong thực tế.
Ở Bài 28 — CRUD Users, chúng ta sẽ xây dựng module quản lý người dùng dành cho quản trị viên:
Danh sách User
Chỉnh sửa thông tin
Thay đổi Role (Admin/User)
Khóa hoặc mở khóa tài khoản
Đặt lại mật khẩu
Sau bài này, Blog CMS sẽ có đầy đủ ba module quản trị cốt lõi: Categories, Posts và Users.
Sau khi hoàn thành module Categories, chúng ta sẽ xây dựng chức năng quan trọng nhất của Blog CMS: quản lý bài viết (Posts).Khác với Category chỉ có v...
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.
OpenAI chuẩn bị gỡ giới hạn chat văn bản trên ChatGPT Free và Go, đồng thời thêm nút Think để xử lý các câu hỏi phức tạp hơn.
Nhật Bản khuyến nghị bảo vệ ‘quyền giọng nói’ của người nổi tiếng trước AI, trong đó hành vi giả giọng để phát tán, kiếm tiền có thể bị xử lý.
🕒 Cập nhật: Thứ Tư, ngày 12 tháng 08 năm 2026 lúc 16:55
Nếu nhu cầu của bạn là giải trí đặc biệt là các nội dung hình ảnh, chơi game thường xuyên thì Galaxy A36 đang sở ...
🕒 Cập nhật: Thứ Tư, ngày 12 tháng 08 năm 2026 lúc 16:33
Meccha Chameleon, tựa game indie được phát triển bởi chỉ hai người, vừa đạt cột mốc đáng kinh ngạc khi vượt mốc 2...
🕒 Cập nhật: Thứ Tư, ngày 12 tháng 08 năm 2026 lúc 16:31
Nếu bạn đang tìm kiếm một chiếc iPhone sở hữu màn hình chất lượng đặc biệt là công nghệ Dynamic Island đặc trưng ...
🕒 Cập nhật: Thứ Tư, ngày 12 tháng 08 năm 2026 lúc 16:30
Giá vàng hôm nay chiều 12/8 ghi nhận đà tăng liên tục từ đầu phiên sáng đến cuối giờ chiều tại các hệ thống kinh ...
🕒 Cập nhật: Thứ Tư, ngày 12 tháng 08 năm 2026 lúc 15:59
MSD Việt Nam lần đầu triển khai chiến dịch truyền thông về HPV dành riêng cho nam giới, với sự đồng hành của nhà ...