Ở 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.
Cập nhật: 2026-08-10T07:05:00.216+07:00
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.
Cập nhật: 2026-08-09T19:23:48.308+07:00
Ở 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.
Cập nhật: 2026-08-09T07:16:00.188+07:00
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.
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.
Cập nhật: 2026-08-10T21:16:41.861+07:00
Ở bài trước, chúng ta điều khiển động cơ bằng cách:
Quay 1 giây
↓
Dừng
Cách này hoạt động khá tốt để thử nghiệm.
Nhưng nếu muốn máy hoạt động liên tục ngoài thực tế thì không đủ chính xác.
Sau bài này bạn sẽ:
Hiểu vì sao không nên điều khiển bằng delay().
Biết Encoder là gì.
Biết Hall Sensor hoạt động như thế nào.
Chọn loại cảm biến phù hợp.
Điều khiển động cơ quay đúng một vòng.
delay() không chính xác?Giả sử hôm nay:
Motor
30 RPM
↓
1 giây
↓
Quay đúng 180°
Một tuần sau:
Adapter yếu hơn.
Lon nặng hơn.
Nhiệt độ cao hơn.
Kết quả:
1 giây
↓
Chỉ quay 150°
Hoặc:
1 giây
↓
Quay 220°
Máy sẽ:
❌ Kẹt hàng
❌ Rơi 2 lon
❌ Không rơi lon nào
Không ai đo thời gian.
Họ đo:
Vị trí của trục
Ví dụ:
0°
↓
45°
↓
90°
↓
180°
↓
360°
Khi đạt đúng vị trí yêu cầu:
Motor dừng.
Encoder giống như chiếc đồng hồ đếm vòng quay.
Ví dụ:
Motor
↓
Encoder
↓
ESP32
Encoder sẽ phát ra xung.
Ví dụ:
1 vòng
=
200 xung
ESP32 chỉ cần đếm.
Ví dụ:
Đếm
0
↓
50
↓
100
↓
150
↓
200
↓
Dừng
Không quan trọng:
Motor nhanh.
Motor chậm.
Miễn đủ:
200 xung
Dùng đĩa có nhiều khe.
LED
↓
||||||||||
↓
Sensor
Độ chính xác cao.
Dùng nam châm.
Nam châm
↓
Hall Sensor
Rẻ.
Bền.
Ít bụi.
Một số motor đã có sẵn encoder phía sau.
Đây là lựa chọn rất tốt nếu ngân sách cho phép.
Đây là loại mình khuyến nghị cho khóa học.
Ví dụ:
Motor
↓
Nam châm
↓
Hall Sensor
Mỗi lần nam châm đi qua:
Sensor phát:
HIGH
LOW
HIGH
LOW
ESP32 chỉ cần đếm.
Nam châm
●
==============
Hall Sensor
Mỗi vòng quay:
Nam châm đi ngang qua Hall Sensor một lần.
Gắn:
● ● ● ●
4 nam châm.
Khi đó:
1 vòng
=
4 xung
Hoặc:
8 nam châm
=
8 xung
Độ chính xác sẽ tăng lên.
Ví dụ:
Hall Sensor
VCC → 3.3V
GND → GND
OUT → GPIO27
Ví dụ:
volatile int count = 0;
void IRAM_ATTR pulse() {
count++;
}
void setup() {
pinMode(27, INPUT_PULLUP);
attachInterrupt(27, pulse, FALLING);
}
Mỗi xung:
count++
Giả sử:
4 xung
=
1 vòng
Thuật toán:
count = 0
↓
Motor chạy
↓
Đếm xung
↓
Đủ 4
↓
Motor dừng
Không cần dùng:
delay()
nữa.
Máy thương mại luôn có thêm cảm biến ở khay nhận hàng.
Ví dụ:
Lon
↓
IR Sensor
↓
ESP32
Nếu:
Lon đã rơi.
ESP32 sẽ biết.
Nếu:
Motor quay.
Nhưng:
Lon bị kẹt.
Thì:
Encoder
OK
Nhưng
Không có lon.
Máy sẽ:
Báo lỗi.
Hoàn tiền.
Thử quay lại.
Button
↓
ESP32
↓
Motor Driver
↓
Motor
↓
Encoder
↓
ESP32
↓
IR Sensor
↓
ESP32
Bây giờ hệ thống đã có:
Đầu vào.
Điều khiển.
Phản hồi.
Đây gọi là:
Closed Loop Control (Điều khiển vòng kín)
Khác với bài trước:
Button
↓
Motor
↓
Hết
Đó là:
Open Loop
Khách bấm
↓
Motor quay
↓
Encoder báo
↓
Đủ 1 vòng
↓
Motor dừng
↓
Lon rơi
↓
IR Sensor xác nhận
↓
Hoàn thành
Nếu:
Không thấy lon
↓
ESP32 báo lỗi
↓
Hiển thị:
"Sản phẩm bị kẹt"
| Thiết bị | Số lượng |
|---|---|
| ESP32 | 1 |
| Driver L298N | 1 |
| Motor giảm tốc | 1 |
| Hall Sensor A3144 | 1 |
| Nam châm Neodymium | 2–4 |
| Cảm biến hồng ngoại | 1 |
Gắn một nam châm nhỏ lên trục động cơ hoặc khớp nối.
Đặt Hall Sensor cách nam châm khoảng 2–5 mm.
Viết chương trình đếm số xung khi động cơ quay.
Thử tăng số nam châm từ 1 lên 4 và so sánh độ chính xác.
Gắn cảm biến hồng ngoại ở khay nhận hàng để phát hiện khi một lon nước rơi xuống.
Sau bài này, chiếc máy của bạn đã chuyển từ mô hình điều khiển theo thời gian sang điều khiển có phản hồi, gần giống nguyên lý của các máy bán hàng tự động chuyên nghiệp.
Bạn đã biết:
Vì sao delay() không phù hợp cho sản phẩm thực tế.
Cách dùng Hall Sensor hoặc Encoder để xác định vị trí quay.
Vai trò của cảm biến xác nhận sản phẩm.
Khái niệm điều khiển vòng hở và vòng kín.
🥤 Bài 6 — Thiết kế phần mềm điều khiển: State Machine
Thay vì viết các câu lệnh if...else dài và khó bảo trì, chúng ta sẽ xây dựng chương trình theo mô hình State Machine, giúp máy xử lý rõ ràng các trạng thái như Chờ khách, Thanh toán, Nhả hàng, Kiểm tra cảm biến, Hoàn tất và Báo lỗi. Đây cũng là cách tổ chức phần mềm được sử dụng phổ biến trong các thiết bị nhúng và máy bán hàng thương mại.
Cập nhật: 2026-08-08T07:19:00.209+07:00
Sau khi đã hoàn thành phần cơ khí và hiểu nguyên lý hoạt động của trục xoắn, bước tiếp theo là lựa chọn động cơ và bộ điều khiển phù hợp.
Đây là phần quyết định chiếc máy của bạn có hoạt động ổn định hay không.
Sau bài này bạn sẽ:
Phân biệt động cơ DC, Servo và Stepper.
Biết loại nào phù hợp nhất cho máy bán nước.
Hiểu cách chọn mô-men xoắn.
Điều khiển một động cơ bằng ESP32.
Nhấn một nút và nhả đúng một lon nước.
Hầu hết máy bán hàng hiện nay sử dụng:
Động cơ DC giảm tốc 24V
Có hộp số
Mô-men xoắn lớn
Tốc độ chậm
Ví dụ:
Motor
──────
Gearbox
──────
Spiral
Không phải động cơ quay nhanh.
Mà là động cơ quay rất khỏe.
Ưu điểm
✅ Giá rẻ
✅ Dễ mua
✅ Lực mạnh
✅ Dễ điều khiển
Nhược điểm
❌ Không biết chính xác đã quay bao nhiêu độ.
Ưu điểm
✅ Quay đúng góc
✅ Điều khiển đơn giản
Nhược điểm
❌ Lực yếu
❌ Khó kéo nhiều lon nước
❌ Không phù hợp máy lớn
Ưu điểm
✅ Chính xác
✅ Quay đúng số bước
✅ Không cần encoder
Nhược điểm
❌ Driver đắt hơn
❌ Tiêu thụ điện lớn
Đối với phiên bản mini:
| Động cơ | Đánh giá |
|---|---|
| DC giảm tốc | ⭐⭐⭐⭐⭐ |
| Stepper NEMA17 | ⭐⭐⭐⭐☆ |
| Servo MG996R | ⭐⭐☆☆☆ |
Khóa học này sẽ sử dụng:
Motor DC giảm tốc 12V
Lý do:
Rẻ
Mạnh
Dễ thay thế
Nhiều bạn chỉ nhìn điện áp.
Đây là sai lầm.
Điều quan trọng hơn là:
Torque (Mô-men xoắn)
Ví dụ:
Motor A
100 RPM
0.3 kg.cm
và
Motor B
30 RPM
8 kg.cm
Motor B khỏe hơn rất nhiều.
Ví dụ:
6 lon Coca
mỗi lon khoảng 350g
Tổng:
6 × 0.35
≈ 2.1kg
Để đẩy được toàn bộ hàng nước, nên chọn động cơ có dự phòng lực kéo. Thực tế, bạn sẽ phải tính đến ma sát giữa lon, trục xoắn và khay đỡ.
Mô-men xoắn phụ thuộc vào lực và bán kính tác dụng:
genui{"physics_motion_forces":{"type_id":"TORQUE"}}
Đối với mô hình mini, bạn không cần tính chính xác đến từng đơn vị. Một động cơ giảm tốc có mô-men xoắn khoảng 5–10 kg·cm thường đủ để thử nghiệm một ngăn chứa vài lon nước.
Không nên quá nhanh.
Ví dụ:
| RPM | Đánh giá |
|---|---|
| 300 | Quá nhanh |
| 100 | Hơi nhanh |
| 30 | Tốt |
| 20 | Rất tốt |
Khuyến nghị:
20–40 RPM
Chúng ta sẽ dùng:
ESP32 DEVKIT V1
Ưu điểm:
WiFi
Bluetooth
Nhiều GPIO
Giá rẻ
Có thể mở rộng:
QR
Web Server
OTA
MQTT
ESP32 không thể cấp điện trực tiếp cho động cơ.
Cần dùng Driver.
Ví dụ:
ESP32
↓
L298N
↓
Motor
ESP32
GPIO18 ------------ IN1
GPIO19 ------------ IN2
GND --------------- GND
L298N
OUT1 ------------ Motor
OUT2 ------------ Motor
12V ------------- Adapter
Kết nối:
Button
Một chân → GND
Một chân → GPIO4
ESP32 sẽ sử dụng điện trở kéo lên (INPUT_PULLUP), nên khi nhấn nút, chân GPIO sẽ đọc mức LOW.
Ý tưởng rất đơn giản:
Nếu nhấn nút
↓
Motor quay
↓
1 giây
↓
Dừng
Ví dụ chương trình trong Arduino IDE:
const int BUTTON = 4;
const int IN1 = 18;
const int IN2 = 19;
void setup() {
pinMode(BUTTON, INPUT_PULLUP);
pinMode(IN1, OUTPUT);
pinMode(IN2, OUTPUT);
}
void loop() {
if (digitalRead(BUTTON) == LOW) {
digitalWrite(IN1, HIGH);
digitalWrite(IN2, LOW);
delay(1000);
digitalWrite(IN1, LOW);
digitalWrite(IN2, LOW);
delay(300);
}
}
Nhấn nút
↓
ESP32
↓
Driver
↓
Motor quay
↓
Spiral quay
↓
Lon rơi
Đây chính là nguyên lý hoạt động của máy bán hàng tự động.
Nếu:
điện áp yếu
motor cũ
tải nặng
Thì:
1 giây
không còn tương ứng với một vòng quay như mong muốn.
Do đó máy thương mại thường dùng:
Encoder
Hall Sensor
Optical Sensor
để biết chính xác trục đã quay bao nhiêu.
Ở các bài sau, chúng ta cũng sẽ bổ sung cảm biến để tăng độ chính xác.
Chuẩn bị:
ESP32 DevKit.
Driver L298N.
Động cơ DC giảm tốc 12V.
Nút nhấn.
Adapter 12V.
Đấu dây theo sơ đồ trong bài.
Nạp chương trình và kiểm tra:
Nhấn nút → động cơ quay khoảng 1 giây → dừng.
Thử thay đổi delay(1000) thành 500 hoặc 1500 để quan sát sự khác biệt về góc quay.
Sau bài này, bạn đã hoàn thành phần điện tử đầu tiên của dự án:
Hiểu ưu và nhược điểm của từng loại động cơ.
Biết vì sao mô-men xoắn quan trọng hơn tốc độ.
Điều khiển động cơ bằng ESP32 thông qua driver.
Viết chương trình đầu tiên để điều khiển cơ cấu nhả sản phẩm.
🥤 Bài 5 — Điều khiển chính xác: Cảm biến và Encoder
Trong bài này, chúng ta sẽ biến chiếc máy từ một mô hình "quay theo thời gian" thành một hệ thống có khả năng quay đúng vị trí và nhả chính xác một lon nước, giống cách các máy bán hàng tự động thương mại vận hành.
x1
quay về MỤC LỤC
Cập nhật: 2026-08-07T20:22:20.337+07:00
Đây là trái tim của máy bán nước tự động. Nếu trục xoắn hoạt động không chính xác, máy sẽ dễ bị kẹt hoặc nhả nhiều hơn một sản phẩm.
Sau bài này bạn sẽ:
Hiểu nguyên lý của trục xoắn.
Biết cách chọn kích thước phù hợp.
Biết cách tự chế một trục xoắn đơn giản.
Chuẩn bị lắp động cơ ở bài sau.
Trục xoắn (Spiral Dispenser) là một thanh thép được uốn thành hình lò xo.
Nó có nhiệm vụ:
Giữ sản phẩm.
Đẩy sản phẩm tiến về phía trước.
Chỉ nhả đúng 1 sản phẩm khi động cơ quay.
Ví dụ:
Nhìn từ trên xuống
┌────────────────────────────┐
🥤 🥤 🥤 🥤 🥤
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
└────────────────────────────┘
@ = Trục xoắn
Ban đầu:
🥤🥤🥤🥤🥤
@@@@@@@@@@@@
Sau khi động cơ quay:
🥤🥤🥤🥤
@@@@@@@@@@@
🥤
↓
Lon đầu tiên đi qua mép khay và rơi xuống.
Các lon phía sau chỉ tiến lên một khoảng ngắn.
Không phải lúc nào cũng quay một vòng (360°).
Có 3 cách phổ biến:
| Góc quay | Ứng dụng |
|---|---|
| 90° | Hộp nhỏ |
| 180° | Chai nhỏ |
| 360° | Lon nước, snack |
Ở phiên bản mini, chúng ta sẽ điều khiển quay đúng 1 vòng (360°) vì dễ lập trình và dễ hiệu chỉnh.
Ví dụ bán lon Coca 330ml:
Đường kính lon khoảng 66 mm.
Nên chọn:
| Thông số | Giá trị |
|---|---|
| Đường kính trục | 70–75 mm |
| Bước xoắn | 75–80 mm |
| Chiều dài | 30–40 cm |
Nguyên tắc:
Trục quá nhỏ → lon bị kẹt.
Trục quá lớn → lon dễ đổ.
Bước xoắn quá ngắn → khó di chuyển.
Bước xoắn quá dài → có thể nhả hai lon.
Có thể dùng:
Ưu điểm:
Rẻ.
Chịu lực tốt.
Nhược điểm:
Khó uốn.
Ưu điểm:
Chính xác.
Dễ thay đổi kích thước.
Nhược điểm:
Cần máy in 3D.
Hiện nay có thể mua:
Spiral Coil
Vending Machine Spiral
Nhiều kích thước khác nhau.
Giữa trục xoắn cần có một thanh thép.
Ví dụ:
====================
@@@@@@@@@@@@@@@@@@@@
====================
Thanh này giúp:
Không bị cong.
Truyền lực từ động cơ.
Đầu trục:
Motor
█████
│
Khớp nối
│
=================
@@@@@@@@@@@@@@@@@
Không nên gắn trực tiếp vào trục motor vì:
Dễ lệch tâm.
Dễ rung.
Mau hỏng bạc đạn.
Nên dùng khớp nối trục (shaft coupler).
Một đầu nối với motor.
Đầu còn lại nên có bạc đạn.
Motor ================= Bearing
Nếu không có bạc đạn:
Trục dễ võng.
Quay nặng.
Hao điện.
Máy thương mại thường có thêm vách chặn.
Ví dụ:
🥤🥤🥤🥤
@@@@@@@@@@@@
────────────
Vách chặn
Vách này giữ lon thứ hai lại khi lon đầu tiên rơi.
Ở bản mini, bạn có thể dùng một thanh nhựa hoặc mica nhỏ để thử nghiệm.
Làm một trục xoắn duy nhất.
Đặt:
5 lon.
Quay 10 lần.
Ghi lại:
| Lần | Kết quả |
|---|---|
| 1 | ✅ |
| 2 | ✅ |
| 3 | ❌ Kẹt |
| 4 | ✅ |
| 5 | ❌ Rơi 2 lon |
Nếu xuất hiện lỗi, hãy điều chỉnh:
Góc quay.
Bước xoắn.
Khoảng cách giữa lon và vách chặn.
Đừng vội lắp 4 ngăn cùng lúc. Một ngăn hoạt động ổn định trước sẽ giúp bạn tiết kiệm rất nhiều thời gian.
❌ Trục xoắn quá sát đáy → quay rất nặng.
❌ Trục xoắn quá cao → lon lọt xuống dưới.
❌ Động cơ quá yếu → không đủ mô-men xoắn.
❌ Khung bị lệch → trục cọ vào thành.
❌ Không có bạc đạn → trục rung mạnh.
Máy bán hàng thương mại không chỉ bán lon nước. Tùy loại sản phẩm sẽ dùng cơ cấu khác nhau:
| Sản phẩm | Cơ cấu |
|---|---|
| Lon nước | Trục xoắn |
| Bánh snack | Trục xoắn |
| Chai nước lớn | Trục xoắn cỡ lớn hoặc khay đẩy |
| Hộp cơm | Khay trượt |
| Thuốc | Ngăn kéo định vị |
| Điện thoại, tai nghe | Cánh tay gắp hoặc khay khóa điện |
Đó là lý do bạn thấy nhiều máy bán hàng có hình thức giống nhau nhưng bên trong lại rất khác.
Sau bài này, bạn đã nắm được bộ phận quan trọng nhất của máy bán nước tự động:
Hiểu nguyên lý hoạt động của trục xoắn.
Biết cách chọn kích thước theo sản phẩm.
Biết cách lắp trục với động cơ.
Biết các lỗi thường gặp và cách khắc phục.
🥤 Bài 4 — Chọn động cơ và điều khiển bằng ESP32
Chúng ta sẽ tìm hiểu:
Động cơ DC, động cơ bước hay servo phù hợp hơn?
Cách tính mô-men xoắn cần thiết.
Chọn driver điều khiển.
Viết chương trình đầu tiên để nhả đúng một lon nước chỉ bằng một nút bấm.
Cập nhật: 2026-08-06T07:39:00.211+07:00
Từ bài này chúng ta sẽ bắt đầu xây dựng dự án Blog CMS hoàn chỉnh.
Chức năng đầu tiên là quản lý danh mục bài viết (Categories) với đầy đủ các thao tác thêm – sửa – xóa – tìm kiếm – phân trang.
Tạo Resource Controller cho Category.
Xây dựng giao diện quản trị bằng Blade + Tailwind CSS.
Validate dữ liệu đầu vào.
Tìm kiếm theo tên danh mục.
Phân trang dữ liệu.
Hoàn thiện module CRUD chuẩn Laravel 12.
Trường hợp 01: các bài trước đã tạo category rồi nhưng thiếu columns.
php artisan make:migration add_slug_and_description_to_categories_table --table=categories
Trong file migration:
use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { Schema::table('categories', function (Blueprint $table) { $table->string('slug')->unique()->after('name'); $table->text('description')->nullable()->after('slug'); }); } public function down(): void { Schema::table('categories', function (Blueprint $table) { $table->dropColumn(['slug', 'description']); }); } };
Mở:
database/factories/CategoryFactory.php
Nếu đang là:
return [ 'name' => fake()->words(2, true), ];
đổi thành:
use Illuminate\Support\Str; return [ 'name' => $name = fake()->unique()->words(2, true), 'slug' => Str::slug($name), 'description' => fake()->sentence(), ];
File seeder:
use App\Models\Category; use Illuminate\Support\Str; public function run(): void { foreach (['Laravel', 'PHP', 'MySQL'] as $name) { Category::create([ 'name' => $name, 'slug' => Str::slug($name), 'description' => "Danh mục {$name}", ]); } }
PHP
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Category extends Model
{
use HasFactory;
protected $fillable = [
'name',
'slug',
'description'
];
}php artisan migrate:fresh --seed
Trường hợp 2: nếu chưa có Category, tạo nhanh bằng Artisan:
Bash
php artisan make:model Category -mcr
Laravel sẽ tạo:
app/Models/Category.php
database/migrations/xxxx_create_categories_table.php
app/Http/Controllers/CategoryController.php
PHP
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->timestamps();
});
Chạy migration:
Bash
php artisan migrate
PHP
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Category extends Model
{
use HasFactory;
protected $fillable = [
'name',
'slug',
'description'
];
}
Mở routes/web.php:
PHP
use App\Http\Controllers\CategoryController;
Route::middleware(['auth'])->group(function () {
Route::resource('categories', CategoryController::class);
});
Laravel tự tạo 7 route chuẩn:
php artisan route:list
| Method | URL |
|---|---|
| GET | /categories |
| GET | /categories/create |
| POST | /categories |
| GET | /categories/{"{category}"} |
| GET | /categories/{"{category}"}/edit |
| PUT | /categories/{"{category}"} |
| DELETE | /categories/{"{category}"} |
PHP (pagination bằng Laravel)
use App\Models\Category;
use Illuminate\Http\Request;
public function index(Request $request)
{
$categories = Category::query()
->when($request->keyword, function ($query, $keyword) {
$query->where('name', 'like', "%{$keyword}%");
})
->latest()
->paginate(10)
->withQueryString();
return view('categories.index', compact('categories'));
}
PHP
<x-app-layout>
<div class="max-w-7xl mx-auto p-6">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">Quản lý Categories</h1>
<a href="{{ route('categories.create') }}"
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700">
+ Thêm Category
</a>
</div>
<form method="GET" class="mb-4">
<input type="text"
name="keyword"
value="{{ request('keyword') }}"
placeholder="Tìm kiếm category..."
class="border rounded-lg px-4 py-2 w-80">
<button class="bg-gray-800 text-white px-4 py-2 rounded-lg">
Tìm
</button>
</form>
<div class="bg-white rounded-xl shadow overflow-hidden">
<table class="min-w-full">
<thead class="bg-gray-100">
<tr>
<th class="px-4 py-3 text-left">ID</th>
<th class="px-4 py-3 text-left">Tên</th>
<th class="px-4 py-3 text-left">Slug</th>
<th class="px-4 py-3 text-left">Ngày tạo</th>
<th class="px-4 py-3 text-right">Thao tác</th>
</tr>
</thead>
<tbody class="divide-y">
@forelse($categories as $category)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">{{ $category->id }}</td>
<td class="px-4 py-3 font-medium">
{{ $category->name }}
</td>
<td class="px-4 py-3 text-gray-600">
{{ $category->slug }}
</td>
<td class="px-4 py-3">
{{ $category->created_at->format('d/m/Y') }}
</td>
<td class="px-4 py-3 text-right space-x-2">
<a href="{{ route('categories.edit', $category) }}"
class="text-blue-600 hover:underline">
Sửa
</a>
<form action="{{ route('categories.destroy', $category) }}"
method="POST"
class="inline">
@csrf
@method('DELETE')
<button
onclick="return confirm('Xóa category này?')"
class="text-red-600 hover:underline">
Xóa
</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="text-center py-8 text-gray-500">
Chưa có dữ liệu
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-6">
{{ $categories->links() }}
</div>
</div>
</x-app-layout>
PHP
<form action="{{ route('categories.store') }}" method="POST"> @csrf <div class="mb-4"> <label class="block mb-1">Tên Category</label> <input type="text" name="name" value="{{ old('name') }}" class="w-full border rounded-lg px-4 py-2"> @error('name') <p class="text-red-500 text-sm mt-1">{{ $message }}</p> @enderror </div> <div class="mb-4"> <label class="block mb-1">Slug</label> <input type="text" name="slug" value="{{ old('slug') }}" class="w-full border rounded-lg px-4 py-2"> </div> <div class="mb-4"> <label class="block mb-1">Mô tả</label> <textarea name="description" class="w-full border rounded-lg px-4 py-2" rows="4">{{ old('description') }}</textarea> </div> <button class="bg-blue-600 text-white px-6 py-2 rounded-lg"> Lưu </button> </form>
PHP
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|min:2|max:100',
'slug' => 'required|unique:categories,slug',
'description' => 'nullable|max:1000',
]);
Category::create($validated);
return redirect()
->route('categories.index')
->with('success', 'Thêm category thành công!');
}
PHP
public function edit(Category $category)
{
return view('categories.edit', compact('category'));
}
PHP
public function update(Request $request, Category $category)
{
$validated = $request->validate([
'name' => 'required|min:2|max:100',
'slug' => 'required|unique:categories,slug,' . $category->id,
'description' => 'nullable|max:1000',
]);
$category->update($validated);
return redirect()
->route('categories.index')
->with('success', 'Cập nhật thành công!');
}
Điểm quan trọng là rule:
PHP
'slug' => 'required|unique:categories,slug,' . $category->id
Điều này cho phép giữ nguyên slug hiện tại mà không bị báo lỗi trùng.
Edit chỉ khác Create ở 4 điểm:
action → categories.update
@method('PUT')
old(..., $category->...)
resources/views/categories/edit.blade.php
<x-app-layout> <x-slot name="header"> <h2 class="font-semibold text-xl text-gray-800"> Sửa Category </h2> </x-slot> <div class="py-8"> <div class="max-w-3xl mx-auto sm:px-6 lg:px-8"> <div class="bg-white rounded-xl shadow border border-gray-200"> <div class="px-6 py-4 border-b"> <h3 class="text-lg font-semibold"> Cập nhật thông tin Category </h3> </div> <form action="{{ route('categories.update', $category) }}" method="POST"> @csrf @method('PUT') <div class="p-6"> <div class="mb-5"> <label class="block mb-2 font-medium"> Tên Category </label> <input type="text" name="name" value="{{ old('name', $category->name) }}" class="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-200"> @error('name') <p class="text-red-600 text-sm mt-2"> {{ $message }} </p> @enderror </div> <div class="mb-5"> <label class="block mb-2 font-medium"> Slug </label> <input type="text" name="slug" value="{{ old('slug', $category->slug) }}" class="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-200"> @error('slug') <p class="text-red-600 text-sm mt-2"> {{ $message }} </p> @enderror </div> <div class="mb-5"> <label class="block mb-2 font-medium"> Mô tả </label> <textarea name="description" rows="5" class="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-200">{{ old('description', $category->description) }}</textarea> @error('description') <p class="text-red-600 text-sm mt-2"> {{ $message }} </p> @enderror </div> </div> <div class="border-t bg-gray-50 px-6 py-4 flex justify-end gap-3"> <a href="{{ route('categories.index') }}" class="px-5 py-2 rounded-lg border border-gray-300 hover:bg-gray-100"> Hủy </a> <button class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg"> Cập nhật </button> </div> </form> </div> </div> </div> </x-app-layout>
abc
PHP
public function destroy(Category $category)
{
$category->delete();
return redirect()
->route('categories.index')
->with('success', 'Đã xóa category!');
}
Trong bài 30 chúng ta sẽ nâng cấp thành Soft Delete.
Chức năng tìm kiếm đã được tích hợp trong phương thức index():
PHP
->when($request->keyword, function ($query, $keyword) {
$query->where('name', 'like', "%{$keyword}%");
})
Ví dụ:
Nhập laravel → hiển thị các category chứa từ “laravel”.
Nhập php → hiển thị các category liên quan PHP.
Để trống → hiển thị toàn bộ dữ liệu.
Nhờ withQueryString(), khi chuyển trang Laravel vẫn giữ lại từ khóa tìm kiếm.
PHP
->paginate(10)
Hiển thị:
PHP
{{ $categories->links() }}
Laravel 12 mặc định sử dụng giao diện phân trang tương thích Tailwind CSS.
Thêm vào đầu trang:
PHP
@if(session('success'))
<div class="mb-4 rounded-lg bg-green-100 border border-green-300
text-green-800 px-4 py-3">
{{ session('success') }}
</div>
@endif
Kết quả:
Thêm mới → “Thêm category thành công!”
Cập nhật → “Cập nhật thành công!”
Xóa → “Đã xóa category!”
app/
├── Http/
│ └── Controllers/
│ └── CategoryController.php
├── Models/
│ └── Category.php
resources/
└── views/
└── categories/
├── index.blade.php
├── create.blade.php
└── edit.blade.php
routes/
└── web.php
Sau bài học này, module Quản lý Categories của Blog CMS đã có đầy đủ:
CRUD
Quản lý dữ liệu
Danh sách Categories
Thêm mới Category
Cập nhật Category
Xóa Category
Validation
Kiểm tra dữ liệu
Validate tên danh mục
Validate slug duy nhất
Hiển thị lỗi ngay trên form
Search
Tìm kiếm
Lọc theo tên category
Giữ từ khóa khi chuyển trang
Pagination
Phân trang
10 bản ghi mỗi trang
Giao diện Tailwind mặc định của Laravel 12
Đây chính là mẫu CRUD chuẩn mà chúng ta sẽ tái sử dụng cho nhiều module khác trong dự án.
Đâ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'
])
<div class="mb-5">
<label
for="{{ $name }}"
class="block text-sm font-semibold text-gray-700 mb-2">
{{ $label }}
</label>
<input
id="{{ $name }}"
type="{{ $type }}"
name="{{ $name }}"
value="{{ old($name) }}"
{{ $attributes->merge([
'class' =>
'w-full rounded-lg border border-gray-300 bg-white
px-4 py-2.5
focus:border-blue-500
focus:ring-4
focus:ring-blue-100
outline-none
transition'
]) }}>
@error($name)
<p class="text-red-600 text-sm mt-2">
{{ $message }}
</p>
@enderror
</div>
@props([
'label',
'name',
'rows' => 4
])
<div class="mb-5">
<label
class="block text-sm font-semibold text-gray-700 mb-2">
{{ $label }}
</label>
<textarea
name="{{ $name }}"
rows="{{ $rows }}"
{{ $attributes->merge([
'class' =>
'w-full rounded-lg border border-gray-300
px-4 py-2.5
focus:border-blue-500
focus:ring-4
focus:ring-blue-100'
]) }}>{{ old($name) }}</textarea>
@error($name)
<p class="text-red-600 text-sm mt-2">
{{ $message }}
</p>
@enderror
</div>
@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.
<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ó.
Sang Bài 27 — CRUD Posts, chúng ta sẽ kết hợp:
Relationship giữa Post và Category.
Gán bài viết cho User.
Upload hình ảnh.
Validate dữ liệu phức tạp hơn.
Hiển thị danh sách bài viết kèm tên danh mục và tác giả.
Từ bài 27 trở đi, dự án Blog CMS sẽ bắt đầu có cấu trúc giống một hệ thống quản trị nội dung thực tế.
x1
Cập nhật: 2026-08-07T21:22:57.625+07:00
Sau bài này bạn sẽ:
Biết cách thiết kế khung máy.
Hiểu kích thước của từng bộ phận.
Chuẩn bị sẵn để lắp động cơ ở bài sau.
Lưu ý: Chúng ta không cố gắng làm giống máy thương mại ngay từ đầu. Mục tiêu là tạo một phiên bản đơn giản, chắc chắn và dễ nâng cấp.
Máy sẽ chia thành 3 tầng:
+---------------------------+
| Bảng điều khiển |
| (Màn hình + Nút bấm) |
+---------------------------+
| |
| Kho chứa sản phẩm |
| 4 ngăn |
| |
+---------------------------+
| Khay nhận sản phẩm |
+---------------------------+
Ví dụ:
| Bộ phận | Kích thước |
|---|---|
| Cao | 60 cm |
| Rộng | 35 cm |
| Sâu | 30 cm |
Đây là kích thước vừa đủ để chứa:
4 ngăn bán hàng
Mỗi ngăn 5–6 lon
ESP32
Adapter
Driver động cơ
Có nhiều lựa chọn:
| Vật liệu | Ưu điểm | Nhược điểm |
|---|---|---|
| MDF 9 mm | Rẻ, dễ cắt | Không chịu nước |
| Mica 5 mm | Đẹp, nhìn thấy bên trong | Giá cao hơn |
| Nhôm định hình | Rất chắc | Chi phí cao |
| In 3D | Chính xác | Chậm nếu kích thước lớn |
👉 Đối với người mới, MDF + mica là lựa chọn cân bằng giữa chi phí và tính thẩm mỹ.
Ví dụ 4 ngăn:
+-------------------------+
| A1 | A2 | A3 | A4 |
| | | | |
| | | | |
| | | | |
+-------------------------+
Mỗi ngăn sẽ có:
1 trục xoắn.
1 động cơ.
5–6 lon nước.
Toàn bộ phần điện sẽ nằm phía sau:
+----------------------+
ESP32
Motor Driver
Nguồn 12V
Dây điện
+----------------------+
Điều này giúp việc sửa chữa dễ dàng hơn.
Phía dưới cần một khay nghiêng nhẹ:
Lon rơi
↓
__________
/
/
___/____________
Góc nghiêng khoảng 10–15° là đủ để lon lăn ra phía trước.
Không cần dùng cửa tự động ở phiên bản đầu.
Chỉ cần:
____________
| |
| |
|____________|
Một cửa mica nhỏ hoặc cửa nhựa có bản lề là đủ.
Mỗi động cơ đặt ở cuối trục xoắn:
Motor
█████
======== Spiral ========
🥤🥤🥤🥤🥤
Motor chỉ quay khi khách chọn sản phẩm.
| Vật tư | Số lượng |
|---|---|
| Tấm MDF 9 mm | 1–2 tấm |
| Tấm mica trong | 1 tấm |
| Bản lề nhỏ | 2 |
| Tay nắm cửa | 1 |
| Ốc vít | 1 bộ |
| Ke góc chữ L | 8–12 cái |
| Keo gỗ | 1 chai |
Để mặt trước bằng mica trong để quan sát sản phẩm.
Chừa nắp phía sau có thể tháo ra khi cần sửa chữa.
Khoan sẵn các lỗ đi dây để việc lắp điện gọn gàng.
Chừa khoảng trống phía trên để sau này lắp màn hình hoặc đầu đọc QR.
Ở bài tiếp theo, chúng ta sẽ làm bộ phận quan trọng nhất của máy bán hàng: trục xoắn (spiral dispenser).
Bạn sẽ học:
Vì sao chỉ một lon được đẩy ra mỗi lần.
Cách chọn kích thước lò xo xoắn.
Cách gắn trục xoắn với động cơ.
Cách tự chế trục xoắn nếu không mua sẵn.
Đây là phần quyết định máy hoạt động mượt hay thường xuyên bị kẹt sản phẩm, nên chúng ta sẽ đi thật chi tiết với hình minh họa và bản vẽ đơn giản.
Cập nhật: 2026-08-04T19:42:01.243+07:00
Sau bài này bạn sẽ hiểu:
Máy bán nước hoạt động như thế nào.
Bên trong có những bộ phận gì.
Những gì chúng ta sẽ tự làm.
Không cần lập trình.
Không cần điện tử.
Chỉ cần hiểu nguyên lý.
Ví dụ khách muốn mua Coca.
Quy trình diễn ra như sau:
Khách chọn số A1
↓
Máy kiểm tra thanh toán
↓
Động cơ A1 quay
↓
Lon Coca rơi xuống
↓
Cảm biến phát hiện
↓
Mở cửa lấy hàng
↓
Kết thúc
Toàn bộ quá trình chỉ khoảng 2–4 giây.
Một máy bán nước có khoảng 6 hệ thống chính:
+---------------------------+
| Màn hình |
+---------------------------+
| Bộ điều khiển |
+---------------------------+
| Bộ nhận thanh toán |
+---------------------------+
| Động cơ các ngăn |
+---------------------------+
| Cảm biến rơi |
+---------------------------+
| Kho chứa nước |
+---------------------------+
Không cần làm máy lớn.
Phiên bản đầu tiên chỉ có 4 ngăn.
Ví dụ:
+--------------------+
🥤 Coca
🥤 Pepsi
🥤 7Up
🥤 Sting
+--------------------+
Mỗi ngăn có một động cơ riêng.
Giả sử khách bấm nút số 2.
ESP32 sẽ nhận tín hiệu.
Nút số 2
↓
ESP32
↓
Motor số 2 quay
↓
Lon rơi
↓
Hoàn thành
Đơn giản vậy thôi.
Đây là phần thông minh nhất.
Bên trong có một trục xoắn (spiral).
Ban đầu:
🥤🥤🥤🥤🥤
~~~~~~~~~~
Khi motor quay một vòng nhỏ:
🥤🥤🥤🥤
🥤↓
~~~~~~~~~~
Chỉ lon đầu tiên bị đẩy ra.
Các lon còn lại vẫn nằm trong hàng.
Đó là nguyên lý mà hầu hết máy bán hàng hiện nay sử dụng.
Không mua máy thật.
Chỉ cần:
ESP32
4 động cơ giảm tốc
4 lò xo xoắn (có thể tự uốn bằng thép hoặc in 3D)
Adapter 12V
Nút nhấn
Khung gỗ hoặc mica
Tổng chi phí vẫn khá thấp so với một máy thương mại.
Bài 1: Hiểu nguyên lý hoạt động. ✅
Bài 2: Thiết kế khung máy mini.
Bài 3: Làm cơ cấu trục xoắn để nhả từng lon.
Bài 4: Điều khiển động cơ bằng ESP32.
Bài 5: Thêm nút chọn sản phẩm.
Bài 6: Cảm biến xác nhận sản phẩm đã rơi.
Bài 7: Hiển thị màn hình OLED/LCD.
Bài 8: Thanh toán bằng mã QR.
Bài 9: Kết nối Wi-Fi và quản lý từ xa.
Bài 10: Hoàn thiện máy bán nước mini.
Hãy quan sát một máy bán nước tự động ở siêu thị hoặc trường học.
Ghi lại:
Có bao nhiêu hàng sản phẩm?
Có dùng tiền mặt, thẻ hay QR?
Sau khi chọn sản phẩm, máy mất bao lâu để nhả hàng?
Thử tưởng tượng nếu thay nước bằng bánh, mì hoặc đồ dùng học tập thì cơ cấu nào phải thay đổi, cơ cấu nào vẫn giữ nguyên?
💡 Mẹo: Từ bài 2 trở đi, chúng ta sẽ bắt đầu thiết kế theo hướng dễ gia công tại Việt Nam, ưu tiên dùng vật liệu như gỗ MDF, mica, nhôm định hình và các linh kiện phổ biến trên thị trường để bạn có thể tự lắp ráp mà không cần xưởng cơ khí chuyên nghiệp.
Cập nhật: 2026-08-03T11:10:00.190+07:00
Sau khi hoàn thành:
Bài 21 — Laravel Breeze
Bài 22 — Login
Bài 23 — Register
Bài 24 — Middleware
Chúng ta đã có hệ thống:
✅ Đăng ký tài khoản
✅ Đăng nhập
✅ Session Authentication
✅ Bảo vệ Route bằng Middleware
Nhưng hiện tại Laravel mới chỉ biết:
"Người này đã đăng nhập hay chưa?"
Ví dụ:
Route::middleware('auth')->group(function(){
Route::get('/dashboard', function(){
return view('dashboard');
});
});
Middleware auth chỉ kiểm tra:
User có đăng nhập?
|
|
Có
|
Cho phép truy cập
Nhưng trong thực tế CMS Blog cần nhiều hơn:
Ví dụ:
User A tạo bài viết:
ID: 10
Title: Laravel 12 Tutorial
User_id: 5
User B đăng nhập.
Nếu User B truy cập:
/posts/10/edit
thì sao?
Nếu chỉ dùng:
Route::middleware('auth')
Laravel sẽ cho phép.
Vấn đề:
User B
|
|
V
Sửa bài viết của User A
Đây là lỗi phân quyền.
Hai khái niệm rất dễ nhầm.
(Authentication = Xác thực)
Câu hỏi:
Bạn là ai?
Ví dụ:
Login:
Email:
admin@gmail.com
Password:
******
Laravel kiểm tra:
Database users
email
password
Kết quả:
OK
User ID = 1
(Authorization = Phân quyền)
Câu hỏi:
Bạn được phép làm gì?
Ví dụ:
User:
id: 5
name: Nam
role: user
Admin:
id: 1
name: Admin
role: admin
Quyền:
| Chức năng | Admin | User |
|---|---|---|
| Xem bài viết | ✅ | ✅ |
| Tạo bài viết | ✅ | ✅ |
| Sửa bài người khác | ✅ | ❌ |
| Xóa user | ✅ | ❌ |
| Quản lý hệ thống | ✅ | ❌ |
Laravel cung cấp 3 cơ chế chính:
Authorization
|
---------------------------------
| | |
Middleware Gate Policy
| | |
Chặn route Logic nhỏ Logic theo Model
Gate dùng cho các quyền đơn giản.
Ví dụ:
Chỉ Admin được vào trang quản trị.
Mở:
app/Providers/AppServiceProvider.php
Thêm:
use Illuminate\Support\Facades\Gate;
use App\Models\User;
public function boot(): void
{
$this->configureDefaults();//dòng mặc định 13x
Gate::define('admin', function(User $user){
return $user->role === 'Admin';//có phân biệt Hoa-thường
});
}
Ý nghĩa:
Khi gọi:
Gate::allows('admin')
Laravel chạy:
$user->role === 'admin'
Nếu:
admin
trả về:
true
Ví dụ:
Tạo AdminController:
php artisan make:controller Admin/AdminController
Trong routes/web.php:
use App\Http\Controllers\Admin\AdminController;
Route::get('/admin', [AdminController::class, 'index']);
tạo trang views/admin/index.blade.php
<h1>Trang quản trị</h1>
<p>Chúc mừng! Bạn đã vượt qua Gate Authorization.</p>
AdminController:
use Illuminate\Support\Facades\Gate;
public function index()
{
if(!Gate::allows('admin')){
abort(403);
}
return view('admin.index');
}
Nếu User thường truy cập:
Chú ý: trong các bài trước nếu đã seeder ra 01 đống users thì vào UserFactory để biết mật khẩu.
/admin
Laravel trả:
403 Forbidden
Laravel có sẵn middleware:
can
Ví dụ:
Route::get('/admin',
[
AdminController::class,
'index'
])
->middleware('can:admin');
Bây giờ:
Admin
|
OK
User
|
403
Gate phù hợp với quyền đơn giản.
Nhưng Blog CMS có nhiều quyền:
Ví dụ Post:
Post
create
read
update
delete
restore
forceDelete
Không nên viết:
if($user->role=='admin')
ở khắp Controller.
Code sẽ rất rối.
Giải pháp:
Policy gom toàn bộ quyền của một Model.
Ví dụ:
PostPolicy.php
Chạy:
php artisan make:policy PostPolicy --model=Post
Laravel tạo:
app
|
Policies
|
PostPolicy.php
Nội dung:
class PostPolicy
{
}
Laravel tạo sẵn:
viewAny()
view()
create()
update()
delete()
restore()
forceDelete()
Ví dụ:
User chỉ sửa bài của mình.
Post:
id
title
content
user_id
User:
id = 5
Post:
user_id = 5
Cho phép.
Code:
public function update(
User $user,
Post $post
){
return $user->id === $post->user_id;
}
Logic:
User đăng nhập
|
|
Post owner?
|
--------------
| |
Yes No
Cho sửa 403
Laravel 12 tự động phát hiện theo convention:
Model:
App\Models\Post
Policy:
App\Policies\PostPolicy
Laravel tự map:
Post
+
PostPolicy
Không cần khai báo.
Ví dụ:
PostController:
public function edit(Post $post)
{
$this->authorize(
'update',
$post
);
return view(
'posts.edit',
compact('post')
);
}
Nếu:
User_id = Post.user_id
cho phép.
Ngược lại:
403 Forbidden
Có thể viết trực tiếp:
Route::get(
'/posts/{post}/edit',
[
PostController::class,
'edit'
]
)
->middleware(
'can:update,post'
);
Laravel hiểu:
can:
update
Model:
post
Trong giao diện:
Ví dụ nút Edit:
@can('update',$post)
<a href="#">
Edit
</a>
@endcan
Nếu User không có quyền:
Nút không xuất hiện.
Chú ý: bài trước mình lỡ cấu hình kỹ quá chỉ cho hiện posts theo user nên k thực hành được theo ví dụ này.
Giờ chuyển sang dấu nút Mở trang Admin trong link dashboard nếu k phải role admin
AppServiceProvider Laravel 12/13.
use App\Models\User; use Illuminate\Support\Facades\Gate; public function boot(): void { $this->configureDefaults(); Gate::define('admin', function (User $user) { return $user->role === 'Admin'; }); }
Trong Blade:
@can('admin') <a href="{{ route('admin.index') }}" class="inline-block px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"> Mở </a> @endcan
Migration:
php artisan make:migration add_role_to_users_table
Migration:
Schema::table('users', function(Blueprint $table){
$table->string('role')
->default('user');
});
Chạy:
php artisan migrate
Database:
users
| id | name | role |
|---|---|---|
| 1 | Admin | admin |
| 2 | Nam | user |
| 3 | Lan | user |
Trong Model User:
public function isAdmin()
{
return $this->role === 'admin';
//nên chuyển sang ký tự thường cho an toàn
//return strtolower($this->role) === 'admin';
}
Sử dụng:
if(auth()->user()->isAdmin()){
echo "Admin";
}
Dashboard
|
|
CRUD Categories
|
|
CRUD Users
|
|
Delete Post
Dashboard
|
|
Create Post
|
|
Edit Post của mình
|
|
Delete Post của mình
User Login
|
|
Authentication
|
|
Middleware auth
|
|
Authorization
|
|
Gate / Policy
|
|
Allow / Deny
app
├── Models
│ ├── User.php
│ └── Post.php
├── Policies
│ └── PostPolicy.php
├── Http
│ ├── Controllers
│ │
│ └── Middleware
routes
└── web.php
Sau bài 25, người học hiểu:
✅ Authentication khác Authorization
✅ Gate trong Laravel
✅ Policy trong Laravel
✅ $this->authorize()
✅ can Middleware
✅ Blade @can
✅ Phân quyền Admin/User
✅ Bảo vệ dữ liệu theo User sở hữu
Chúng ta sẽ nâng cấp từ:
role = admin/user
thành hệ thống thực tế:
Users
|
Roles
|
Permissions
Admin
├── create_post
├── edit_post
├── delete_user
Editor
├── edit_post
User
├── create_post
Kết hợp:
Middleware Admin
Permission table
Role table
Pivot relationship
Phân quyền CRUD hoàn chỉnh cho Blog CMS Laravel 12.
Có 2 lỗi độc lập:
npm run build thất bại ✅ (nguyên nhân gốc)Bạn đang dùng:
postcss.config.js của Tailwind v3
Bạn có:
export default { plugins: { tailwindcss: {}, autoprefixer: {}, }, };
Trong khi với Tailwind v4 phải là:
export default { plugins: { "@tailwindcss/postcss": {}, }, };
Đây là nguyên nhân gây lỗi:
Cannot find module 'autoprefixer'
Sau khi sửa, npm run build chạy bình thường.
Đây không liên quan đến build.
about.blade.php chỉ có:
<h1>Trang About</h1> <div class="bg-blue-600"> Hello Tailwind </div>
nên Laravel trả về đúng đoạn HTML đó, không qua layout chứa:
@vite(['resources/css/app.css', 'resources/js/app.js'])
Khi bọc bằng:
<x-app-layout> ... </x-app-layout>
thì Tailwind mới được nạp và các class mới có tác dụng.
Chú ý: trang about cho mọi users truy cập nên k dùng x-app-layout (dính navbar)
Đây là quy trình đầy đủ để tạo <x-public-layout> bằng Artisan.
php artisan make:component PublicLayout
Laravel sẽ tạo:
app/
└── View/
└── Components/
└── PublicLayout.php
resources/
└── views/
└── components/
└── public-layout.blade.php
resources/views/components/public-layout.blade.phpThay toàn bộ nội dung mặc định:
<div>
<!-- An unexamined life is not worth living. - Socrates -->
</div>
thành:
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ config('app.name') }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="bg-gray-100">
<main class="max-w-7xl mx-auto px-6 py-8">
{{ $slot }}
</main>
</body>
</html>
{{ $slot }} là nơi nội dung của từng trang sẽ được chèn vào.
Ví dụ resources/views/about.blade.php:
<x-public-layout>
<h1 class="text-3xl font-bold mb-6">About</h1>
<div class="bg-orange-600 text-white p-4 rounded-lg">
Hello Tailwind
</div>
</x-public-layout>
Route::view('/about', 'about');
hoặc
Route::get('/about', function () {
return view('about');
});
app/
└── View/
└── Components/
└── PublicLayout.php
resources/
└── views/
├── about.blade.php
└── components/
└── public-layout.blade.php
Đây là cách làm chuẩn khi dùng Blade Components trong Laravel 12, tương tự như các component sẵn có như <x-app-layout>, <x-guest-layout> và <x-input-label>.
x1
Cập nhật: 2026-08-05T21:21:04.361+07:00
Ở các bài trước, chúng ta đã cài đặt Laravel Breeze, đăng ký tài khoản và đăng nhập vào hệ thống.
Tuy nhiên, hiện tại nếu người dùng biết đường dẫn trực tiếp như:
/posts/create
hoặc:
/dashboard
thì liệu họ có thể truy cập được hay không?
Đó chính là nhiệm vụ của Middleware.
Middleware đóng vai trò như một lớp bảo vệ, kiểm tra mọi yêu cầu trước khi cho phép truy cập vào Controller hoặc Route.
Middleware là đoạn mã nằm giữa Request và Controller.
Mọi yêu cầu gửi đến Laravel đều có thể đi qua một hoặc nhiều Middleware.
Sơ đồ:
Người dùng
│
▼
HTTP Request
│
▼
Middleware
│
▼
Controller
│
▼
Response
Nếu Middleware từ chối yêu cầu, Controller sẽ không được thực thi.
Giả sử Blog có các chức năng:
Xem bài viết.
Thêm bài viết.
Chỉnh sửa bài viết.
Xóa bài viết.
Khách chưa đăng nhập:
✅ Được xem bài viết.
❌ Không được tạo bài viết.
❌ Không được sửa bài viết.
❌ Không được xóa bài viết.
Middleware sẽ đảm nhận việc kiểm tra này.
Laravel đã có sẵn Middleware:
auth
Nó sẽ kiểm tra:
Người dùng đã đăng nhập?
│
Có Không
│ │
▼ ▼
Cho phép Chuyển Login
Ví dụ:
use App\Http\Controllers\PostController;
Route::middleware('auth')->group(function () {
Route::resource(
'posts',
PostController::class
);
});
Toàn bộ các Route của PostController đều yêu cầu đăng nhập.
Ví dụ:
Route::get(
'/dashboard',
function () {
return view('dashboard');
}
)->middleware('auth');
Nếu chưa đăng nhập, Laravel sẽ chuyển người dùng đến trang Login.
Đăng xuất khỏi hệ thống.
Sau đó truy cập:
/posts/create
Kết quả:
302 Redirect
↓
/login
Đây là hành vi mặc định của Middleware auth.
Sau khi cài Laravel Breeze, Route dashboard thường được bảo vệ như sau:
Route::get('/dashboard', function () {
return view('dashboard');
})->middleware(['auth']);
Điều này đảm bảo chỉ người dùng đã xác thực mới truy cập được.
Laravel cũng có Middleware:
guest
Ý nghĩa:
Chưa đăng nhập → được truy cập.
Đã đăng nhập → chuyển hướng.
Ví dụ:
Route::middleware('guest')->group(function () {
Route::get(
'/login',
...
);
Route::get(
'/register',
...
);
});
Người đã đăng nhập( k phải guest) sẽ không thể mở lại trang Login hoặc Register.
Một Route có thể sử dụng nhiều Middleware.
Ví dụ:
Route::middleware([
'auth',
'verified',
])->group(function () {
//
});
Laravel sẽ kiểm tra lần lượt:
Đã đăng nhập.
Email đã xác minh.
Nếu một bước không đạt, yêu cầu sẽ bị chặn.
Tạo bằng Artisan:
php artisan make:middleware CheckPostOwner
Laravel sẽ tạo:
app/
└── Http/
└── Middleware/
CheckPostOwner.php
public function handle(
Request $request,
Closure $next
)
{
if (!auth()->check()) {
return redirect()
->route('login');
}
return $next($request);
}
$next($request) nghĩa là cho phép Request tiếp tục đi tới Controller.
Giả sử mỗi Post có:
user_id
Middleware:
$post = $request->route('post');
if (
$post->user_id != auth()->id()
) {
abort(403);
}
return $next($request);
Nếu người dùng không phải là tác giả:
403 Forbidden
Đây là cách bảo vệ dữ liệu rất phổ biến trong Blog CMS.
Chú ý: ví dụ này chỉ minh họa vì Breeze thiết kế để mỗi user chỉ nhìn thấy profile của chính mình nên k cần middleware.
trước khi chạy link /profile nên dùng lệnh tạo CategoryController trước (do đã khai báo trong route nhưng chưa tạo dẫn đến lỗi trong route:list)
/profile/5/edit
Middleware kiểm tra:
$profile = $request
->route('profile');
if (
$profile->user_id != auth()->id()
) {
abort(403);
}
Người dùng chỉ được sửa Profile của chính mình.
Một số Middleware thường gặp:
| Middleware | Chức năng |
|---|---|
| auth | Kiểm tra đăng nhập |
| guest | Chỉ cho khách truy cập |
| verified | Email đã xác minh |
| throttle | Giới hạn số lần gửi Request |
| signed | Kiểm tra URL có chữ ký |
| custom | Middleware tự tạo |
Quy trình xử lý:
Request
│
▼
Middleware
│
▼
Validation
│
▼
Controller
│
▼
Model
│
▼
Database
Middleware luôn hoạt động trước Controller.
Trong bài này chúng ta đã học:
Middleware là gì.
Middleware auth.
Middleware guest.
Bảo vệ Route.
Route Group.
Tạo Middleware bằng Artisan.
$next($request).
abort(403).
Kiểm tra chủ sở hữu của Post và Profile.
Middleware là tuyến phòng thủ đầu tiên của ứng dụng Laravel, giúp bảo vệ các chức năng quan trọng trước khi dữ liệu được xử lý.
Bảo vệ toàn bộ Route posts bằng Middleware auth.
Kiểm tra truy cập /dashboard khi chưa đăng nhập.
Tạo Middleware CheckPostOwner.
Chỉ cho phép tác giả sửa hoặc xóa bài viết của mình.
Áp dụng Middleware cho chức năng chỉnh sửa Profile.
Thử truy cập bằng tài khoản khác và quan sát kết quả 403 Forbidden.
Kết hợp auth và verified cho một Route bất kỳ.
Ở Bài 25, chúng ta sẽ tìm hiểu Authorization, cơ chế phân quyền trong Laravel bằng Gate và Policy, giúp xác định ai được phép thực hiện hành động nào trên từng đối tượng dữ liệu như Post, User hoặc Category.
Phần view posts (hiện tại mới xây dựng có search theo category)
Có thể dùng CDN.
<link rel="stylesheet"
href="https://cdn.datatables.net/2.3.4/css/dataTables.dataTables.min.css">
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.datatables.net/2.3.4/js/dataTables.min.js"></script>
<table id="postsTable" class="min-w-full">
<thead>
<tr>
<th>ID</th>
<th>Tiêu đề</th>
<th>User</th>
<th>Ngày đăng</th>
</tr>
</thead>
<tbody>
@foreach($posts as $post)
<tr>
<td>{{ $post->id }}</td>
<td>{{ $post->title }}</td>
<td>{{ $post->user->name }}</td>
<td>{{ $post->created_at->format('d/m/Y') }}</td>
</tr>
@endforeach
</tbody>
</table>
new DataTable('#postsTable', {
pageLength: 10,
responsive: true,
ordering: true,
searching: true,
info: true,
lengthChange: true
});
Là có ngay
new DataTable('#postsTable', {
language: {
search: "🔍 Tìm kiếm:",
lengthMenu: "Hiển thị _MENU_ dòng",
info: "Hiển thị _START_ đến _END_ / _TOTAL_",
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"
}
});
public function index()
{
$posts = Post::with('user')
->latest()
->get();
return view('posts.index', compact('posts'));
}
Không cần code search nữa vì DataTables search ngay trên trình duyệt.
Khi số lượng bài viết lớn (ví dụ 10.000–100.000 bài), không nên tải tất cả về trình duyệt. Khi đó nên dùng Server-side Processing của DataTables:
return DataTables::eloquent(
Post::with('user')
)->toJson();
Kết hợp với gói Yajra DataTables trong Laravel để:
Mục 4 nên hiển thị all posts cho admin only...
Trong Laravel nên xử lý ở Controller bằng cách kiểm tra role/admin trước khi query.
Ví dụ bạn đang có cột role trong bảng users (admin, editor, user) như các bài trước, có thể làm:
use Illuminate\Support\Facades\Auth;
public function index()
{
$user = Auth::user();
if ($user->isAdmin()) {
// Admin xem tất cả bài viết
$posts = Post::with('user')
->latest()
->get();
} else {
// User thường chỉ xem bài của mình
$posts = Post::with('user')
->where('user_id', $user->id)
->latest()
->get();
}
return view('posts.index', compact('posts'));
}
Sau đó thêm function isAdmin() trong User Model:
app/Models/User.php
public function isAdmin()
{
return strtolower($this->role) === 'admin';
}
Khi đó logic sẽ là:
| User | Query |
|---|---|
| Admin | Post::all() → thấy tất cả posts |
| User | where('user_id', Auth::id()) → chỉ thấy posts của mình |
Có thể viết gọn hơn bằng toán tử 3 ngôi:
public function index()
{
$user = Auth::user();
$posts = Post::with('user')
->when(!$user->isAdmin(), function ($query) use ($user) {
$query->where('user_id', $user->id);
})
->latest()
->get();
return view('posts.index', compact('posts'));
}
Cách này Laravel style hơn vì chỉ có một query chain.
Sau này mở rộng thêm:
Admin → CRUD tất cả
Editor → sửa bài được giao
User → chỉ CRUD bài của mình
thì nên chuyển sang Laravel Policy + Gate thay vì kiểm tra trực tiếp trong Controller.
x1
Cập nhật: 2026-08-02T17:14:52.404+07:00
Một phiên bản mini sẽ giúp bạn học gần như toàn bộ nguyên lý của máy bán hàng tự động mà chi phí thấp hơn rất nhiều.
Làm một máy cao khoảng 40–60 cm, chứa được:
6–12 lon nước ngọt mini hoặc chai nhỏ
4–6 ô bán hàng
Thanh toán bằng nút bấm hoặc QR
Tự nhả đúng 1 sản phẩm
| Thiết bị | Giá tham khảo |
|---|---|
| ESP32 | 180.000đ |
| Driver động cơ | 60.000đ |
| Motor giảm tốc 12V | 120.000đ × 4 |
| Adapter 12V | 150.000đ |
| Màn hình LCD OLED | 120.000đ |
| Nút bấm | 20.000đ |
| Cảm biến hồng ngoại | 50.000đ |
| Khung mica hoặc gỗ | 300.000–800.000đ |
Tổng chi phí khoảng 1,5–2,5 triệu đồng nếu chưa tích hợp thanh toán.
Khách bấm nút
↓
ESP32 kiểm tra
↓
Motor quay khoảng 180–360°
↓
Lon rơi xuống
↓
Cảm biến xác nhận
↓
Hiển thị "Cảm ơn"
Phiên bản 1:
✅ Bấm nút để lấy nước.
Phiên bản 2:
✅ Thêm màn hình hiển thị.
Phiên bản 3:
✅ Quét mã QR để thanh toán.
Phiên bản 4:
✅ Wi-Fi, quản lý từ xa bằng Node.js.
Phiên bản 5:
✅ Thêm hệ thống làm lạnh.
Dự án này không chỉ là "máy bán nước". Sau khi hoàn thành, bạn sẽ học được:
Lập trình vi điều khiển (ESP32).
Điều khiển động cơ DC.
Đọc cảm biến.
Giao tiếp Wi-Fi.
Thiết kế cơ khí đơn giản.
Kết nối phần mềm quản lý với phần cứng.
Đây là 01 dự án rất phù hợp để mở rộng nếu sau này bạn muốn học về IoT hoặc tự động hóa.
Theo mình, chỉ cần khoảng 10–12 bài học, mỗi bài thực hiện một phần nhỏ, là bạn có thể hoàn thành một máy bán nước mini hoạt động thật.
Sau đó chỉ việc mở rộng số ngăn và bổ sung các tính năng để tiến gần đến một máy thương mại.
Cập nhật: 2026-08-02T19:42:13.160+07:00
Ở bài trước, chúng ta đã tìm hiểu cách đăng nhập (Login) hoạt động trong Laravel.
Trong bài này, chúng ta sẽ tìm hiểu Register – chức năng tạo tài khoản mới cho người dùng.
Laravel Breeze đã tạo sẵn toàn bộ quy trình đăng ký, nhưng hiểu cách nó hoạt động sẽ giúp bạn dễ dàng tùy biến theo yêu cầu của từng dự án.
Register là quá trình tạo một tài khoản mới.
Thông thường người dùng sẽ nhập:
Họ tên
Mật khẩu
Xác nhận mật khẩu
Sau khi đăng ký thành công, Laravel sẽ:
Kiểm tra dữ liệu.
Mã hóa mật khẩu.
Lưu User vào Database.
Đăng nhập tự động.
Chuyển tới Dashboard.
Người dùng
│
▼
Điền Form Register
│
▼
Validation
│
▼
Kiểm tra Email
│
▼
Hash Password
│
▼
Lưu User
│
▼
Đăng nhập tự động
│
▼
Dashboard
Đây là quy trình mặc định của Laravel Breeze.
Laravel Breeze đã tạo sẵn Form:
//resources/views/auth/register.blade.php
<form
method="POST"
action="{{ route('register') }}">
@csrf
<input
type="text"
name="name">
<input
type="email"
name="email">
<input
type="password"
name="password">
<input
type="password"
name="password_confirmation">
<button>
Đăng ký
</button>
</form>
Laravel kiểm tra dữ liệu trước khi tạo User.
Ví dụ:
//app/Http/Controllers/Auth/RegisteredUserController.php
$request->validate([
'name' =>
'required|string|max:255',
'email' =>
'required|email|unique:users,email',
'password' =>
'required|confirmed|min:8',
]);
Ý nghĩa:
required: bắt buộc nhập.
unique: Email không được trùng.
confirmed: phải có trường password_confirmation và hai giá trị phải giống nhau.
min:8: mật khẩu tối thiểu 8 ký tự.
Sau khi Validation thành công:
//app/Http/Controllers/Auth/RegisteredUserController.php
//store function
use Illuminate\Support\Facades\Hash;
$user = User::create([
'name' =>
$request->name,
'email' =>
$request->email,
'country_id' =>
$request->country_id,
'password' =>
Hash::make(
$request->password
),
]);
Hash::make() sẽ băm mật khẩu trước khi lưu vào cơ sở dữ liệu.
Ngay sau khi tạo User:
Auth::login($user);
Người dùng không cần nhập lại Email và Password.
Laravel sẽ tạo Session và chuyển sang trang tiếp theo.
Ở các bài trước, chúng ta đã tạo bảng profiles và quan hệ One To One với users.
Cho phép ghi 02 trường phone và address trong bảng profiles
class Profile extends Model
{
protected $fillable = [
'phone',
'address',
];
...
Sau khi tạo User, có thể tạo luôn Profile:
//đặt trước dòng event(new Registered($user));
$user->profile()->create([
'phone' => $request->phone,
'address' => $request->address,
]);
Khi đó, mỗi User mới đều có sẵn một Profile để cập nhật sau này.
Chèn thêm Phone và Address sau phần Email (hoặc trước Password). Mã theo đúng style của Breeze:
<!-- Phone --> <div class="mt-4"> <x-input-label for="phone" :value="__('Phone')" /> <x-text-input id="phone" class="block mt-1 w-full" type="text" name="phone" :value="old('phone')" required autocomplete="tel" /> <x-input-error :messages="$errors->get('phone')" class="mt-2" /> </div> <!-- Address --> <div class="mt-4"> <x-input-label for="address" :value="__('Address')" /> <x-text-input id="address" class="block mt-1 w-full" type="text" name="address" :value="old('address')" required autocomplete="street-address" /> <x-input-error :messages="$errors->get('address')" class="mt-2" /> </div>
Validation:
'phone' => ['required', 'string', 'max:20'],
'address' => ['required', 'string', 'max:255'],Giả sử bảng countries đã có dữ liệu từ Seeder.
Có thể gán quốc gia mặc định:
$user->update([
'country_id' => 1,
]);
Hoặc lấy theo dữ liệu người dùng chọn từ Form:
'country_id' =>
$request->country_id
Việc này giúp mở rộng chức năng đăng ký cho các dự án đa quốc gia.
Blade:
<!-- Country -->
<div class="mt-4">
<x-input-label for="country_id" :value="__('Country')" />
<select
id="country_id"
name="country_id"
class="block mt-1 w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
required>
<option value="">-- Select Country --</option>
@foreach($countries as $country)
<option
value="{{ $country->id }}"
{{ old('country_id') == $country->id ? 'selected' : '' }}>
{{ $country->name }}
</option>
@endforeach
</select>
<x-input-error :messages="$errors->get('country_id')" class="mt-2" />
</div>
Validation:
'country_id' =>
'required|exists:countries,id'
Nhờ đó người dùng chỉ có thể chọn Country hợp lệ.
trong RegisteredUserController cần truyền biến.
Ví dụ:
use App\Models\Country; public function create(): View { $countries = Country::all(); return view('auth.register', compact('countries')); }
Laravel sẽ tự động báo lỗi nếu:
Email đã tồn tại.
Do Rule:
unique:users,email
giúp ngăn tạo nhiều tài khoản với cùng một Email.
Blade:
<input
type="email"
name="email"
value="{{ old('email') }}">
Thông báo:
@error('email')
<div class="text-danger">
{{ $message }}
</div>
@enderror
Nếu Validation thất bại, dữ liệu đã nhập vẫn được giữ lại nhờ old().
Người dùng có thể:
Tạo Post.
Cập nhật Profile.
Upload ảnh.
Quản lý bài viết của mình.
Nhờ auth()->id(), mọi dữ liệu sẽ gắn đúng với tài khoản vừa đăng ký.
Trong dự án thực tế, sau khi Register có thể tự động:
Tạo User
│
▼
Tạo Profile
│
▼
Gán Country
│
▼
Đăng nhập
│
▼
Dashboard
Đây là cách triển khai phổ biến trong các hệ thống quản lý hiện đại.
Trong bài này chúng ta đã học:
Register là gì.
Quy trình tạo tài khoản.
Validation.
Hash::make().
Auth::login().
Tạo Profile tự động.
Gán Country.
Kiểm tra Email trùng.
Hiển thị lỗi Validation.
Đăng ký tài khoản là bước khởi đầu để người dùng tham gia và sử dụng đầy đủ các chức năng của ứng dụng Laravel.
Đăng ký một tài khoản mới.
Kiểm tra Email không được trùng.
Kiểm tra Password tối thiểu 8 ký tự.
Tạo Profile tự động sau khi Register.
Thêm trường chọn Country vào Form Register.
Gán country_id cho User.
Kiểm tra User và Profile đã được tạo trong cơ sở dữ liệu.
Ở Bài 24, chúng ta sẽ tìm hiểu Middleware, cơ chế giúp bảo vệ các Route và chỉ cho phép người dùng đã đăng nhập truy cập vào những khu vực như Dashboard, quản lý bài viết hoặc trang quản trị.
x1
Cập nhật: 2026-08-02T11:05:51.310+07:00
Thuộc phần 3: Asynchronous Programming
Sau bài này, bạn sẽ hiểu:
Promise là gì.
Vì sao Promise ra đời.
Ba trạng thái của Promise.
Cách tạo Promise.
.then()
.catch()
.finally()
Promise Chaining.
Promise trong Node.js thực tế.
Ở bài trước chúng ta đã học Callback.
Ví dụ:
login(function(user){
getProfile(user, function(profile){
getPosts(profile, function(posts){
console.log(posts);
});
});
});
Khi chương trình lớn lên, callback lồng callback sẽ khiến code:
Khó đọc
Khó bảo trì
Khó bắt lỗi
Đây gọi là:
Callback Hell
Hay còn gọi vui là:
Pyramid of Doom
Ví dụ:
step1(function(){
step2(function(){
step3(function(){
step4(function(){
step5(function(){
});
});
});
});
});
Nhìn giống một kim tự tháp.
Đây chính là lý do Promise ra đời.
Promise là một đối tượng đại diện cho kết quả của một tác vụ bất đồng bộ, có thể thành công hoặc thất bại trong tương lai.
Hiểu đơn giản:
Promise giống như một "tờ giấy hứa".
Ví dụ:
Bạn đặt hàng online.
Ngay lúc đặt:
Chưa có hàng
Chưa biết khi nào tới
Shop chỉ hứa:
"Tôi sẽ giao."
Sau vài ngày:
giao thành công
hoặc giao thất bại
Đó chính là Promise.
Đang chờ xử lý.
Ví dụ:
Đang tải dữ liệu...
Thành công.
Ví dụ:
Đã lấy dữ liệu thành công.
Thất bại.
Ví dụ:
Mất kết nối.
Minh họa:
Pending
/ \
/ \
Fulfilled Rejected
const promise = new Promise(function(resolve, reject){
});
Hoặc dùng Arrow Function:
const promise = new Promise((resolve, reject) => {
});
Trong đó:
resolve() → thành công
reject() → thất bại
Ví dụ:
const promise = new Promise((resolve, reject) => {
resolve("Xin chào Node.js");
});
Lấy kết quả:
promise.then(function(data){
console.log(data);
});
Kết quả:
Xin chào Node.js
const promise = new Promise((resolve, reject) => {
reject("Có lỗi xảy ra");
});
Xử lý lỗi:
promise.catch(function(error){
console.log(error);
});
Kết quả:
Có lỗi xảy ra
.then() chạy khi Promise thành công.
Ví dụ:
const promise = new Promise((resolve) => {
resolve(100);
});
promise.then((number) => {
console.log(number);
});
Kết quả:
100
.catch() chạy khi Promise bị lỗi.
Ví dụ:
const promise = new Promise((resolve, reject) => {
reject("Server Error");
});
promise.catch((error)=>{
console.log(error);
});
Kết quả:
Server Error
.finally() luôn chạy dù thành công hay thất bại.
Ví dụ:
promise
.then(data=>{
console.log(data);
})
.catch(err=>{
console.log(err);
})
.finally(()=>{
console.log("Hoàn thành.");
});
Kết quả:
Hoàn thành.
Rất hữu ích khi:
Tắt Loading
Đóng Database
Giải phóng tài nguyên
Ghi log
Ví dụ:
const promise = new Promise((resolve)=>{
setTimeout(()=>{
resolve("Đã xong.");
},3000);
});
Lấy dữ liệu:
promise.then((data)=>{
console.log(data);
});
Sau 3 giây:
Đã xong.
Promise có thể nối tiếp nhau.
Ví dụ:
Promise.resolve(5)
.then(number=>{
return number * 2;
})
.then(number=>{
return number + 10;
})
.then(number=>{
console.log(number);
});
Kết quả:
20
Mỗi .then() nhận kết quả của .then() trước đó.
Ví dụ:
Promise.resolve(10)
.then(number=>{
return new Promise((resolve)=>{
resolve(number * 3);
});
})
.then(result=>{
console.log(result);
});
Kết quả:
30
Promise sẽ tự động chờ Promise mới hoàn thành trước khi chuyển sang .then() tiếp theo.
Ví dụ:
function sleep(ms){
return new Promise((resolve)=>{
setTimeout(resolve, ms);
});
}
sleep(2000)
.then(()=>{
console.log("2 giây đã trôi qua.");
});
Đây là một mẫu rất phổ biến trong lập trình Node.js.
Từ Node.js hiện đại, nhiều API đã hỗ trợ Promise.
Ví dụ:
import { readFile } from "node:fs/promises";
readFile("hello.txt", "utf8")
.then(data=>{
console.log(data);
})
.catch(err=>{
console.log(err);
});
Không còn phải truyền callback như trước.
Node.js hiện đại đã tích hợp sẵn fetch().
Ví dụ:
fetch("https://jsonplaceholder.typicode.com/posts/1")
.then(response => response.json())
.then(data => {
console.log(data);
})
.catch(error => {
console.log(error);
});
Quy trình xử lý:
fetch()
↓
Response
↓
response.json()
↓
Object JavaScript
Đây là cách gọi API rất phổ biến trong các ứng dụng Node.js và trình duyệt.
Chạy nhiều Promise song song.
Ví dụ:
Promise.all([
Promise.resolve("Node.js"),
Promise.resolve("Express"),
Promise.resolve("Prisma")
])
.then(data=>{
console.log(data);
});
Kết quả:
[
'Node.js',
'Express',
'Prisma'
]
Nếu một Promise thất bại thì toàn bộ Promise.all() sẽ bị từ chối (reject).
Trả về Promise hoàn thành đầu tiên.
Ví dụ:
Promise.race([
new Promise(resolve => setTimeout(() => resolve("A"), 3000)),
new Promise(resolve => setTimeout(() => resolve("B"), 1000))
])
.then(data => {
console.log(data);
});
Kết quả:
B
Promise.race() hữu ích khi cần giới hạn thời gian chờ hoặc chọn kết quả phản hồi nhanh nhất.
Trả về Promise thành công đầu tiên.
Ví dụ:
Promise.any([
Promise.reject("Lỗi 1"),
Promise.resolve("Đã thành công"),
Promise.resolve("Dự phòng")
])
.then(result => {
console.log(result);
});
Kết quả:
Đã thành công
Nếu tất cả Promise đều thất bại thì Promise.any() mới phát sinh lỗi.
Chờ tất cả Promise kết thúc, bất kể thành công hay thất bại.
Ví dụ:
Promise.allSettled([
Promise.resolve("OK"),
Promise.reject("Lỗi")
])
.then(results => {
console.log(results);
});
Kết quả:
[
{ status: "fulfilled", value: "OK" },
{ status: "rejected", reason: "Lỗi" }
]
Rất phù hợp khi cần tổng hợp kết quả của nhiều tác vụ độc lập.
Nên dùng Promise khi:
Đọc/Ghi file.
Gọi REST API.
Truy vấn cơ sở dữ liệu.
Upload file.
Xử lý nhiều tác vụ bất đồng bộ.
Làm việc với các thư viện hiện đại của Node.js.
❌ Quên xử lý lỗi bằng .catch().
promise.then(data => {
console.log(data);
});
Nếu Promise bị từ chối (reject), lỗi có thể không được xử lý.
❌ Không return giá trị trong .then().
Promise.resolve(5)
.then(number => {
number * 2;
})
.then(result => {
console.log(result);
});
Kết quả:
undefined
Cần viết:
return number * 2;
❌ Tạo Promise không cần thiết.
Ví dụ:
return new Promise((resolve) => {
resolve(data);
});
Có thể rút gọn:
return Promise.resolve(data);
Hoặc đơn giản:
return data;
Trong bài học này, bạn đã nắm được cách Promise giúp giải quyết hạn chế của Callback bằng cách tổ chức mã nguồn rõ ràng và dễ bảo trì hơn. Bạn đã biết ba trạng thái của Promise, cách sử dụng .then(), .catch(), .finally(), kết hợp nhiều Promise với Promise.all(), Promise.race(), Promise.any(), Promise.allSettled(), cũng như ứng dụng Promise trong đọc file và gọi API.
Ở bài tiếp theo, chúng ta sẽ học Bài 13 — Async / Await, cú pháp hiện đại giúp viết mã bất đồng bộ theo phong cách gần giống mã đồng bộ, ngắn gọn và dễ đọc hơn.
Cập nhật: 2026-08-02T06:59:00.186+07:00
Ở bài trước, chúng ta đã cài đặt Laravel Breeze và có thể đăng ký, đăng nhập vào hệ thống.
Tuy nhiên, khi người dùng nhập Email và Password rồi nhấn Login, phía sau Laravel đã thực hiện những công việc gì?
Trong bài này, chúng ta sẽ tìm hiểu quy trình đăng nhập, Session và cách lấy thông tin người dùng đang đăng nhập để sử dụng trong ứng dụng.
Login (Đăng nhập) là quá trình xác thực người dùng.
Người dùng cung cấp:
Password
Laravel sẽ kiểm tra thông tin này với dữ liệu trong cơ sở dữ liệu.
Nếu hợp lệ:
Tạo Session
Ghi nhớ người dùng.
Cho phép truy cập các trang được bảo vệ.
Nếu không hợp lệ:
Trả về trang đăng nhập.
Hiển thị thông báo lỗi.
Người dùng
│
▼
Nhập Email + Password
│
▼
Request
│
▼
Validation
│
▼
Kiểm tra Database
│
▼
Password Hash
│
▼
Đúng?
│ │
Có Không
│ │
▼ ▼
Tạo Session Báo lỗi
│
▼
Chuyển đến Dashboard
Đây là quy trình xác thực cơ bản trong Laravel.
Laravel Breeze đã tạo sẵn Form views/auth/login.blade.php:
<form method="POST"
action="{{ route('login') }}">
@csrf
<input
type="email"
name="email">
<input
type="password"
name="password">
<button type="submit">
Đăng nhập
</button>
</form>
Người dùng không cần tự viết từ đầu.
Trước khi xác thực, Laravel kiểm tra dữ liệu:
app/Http/Requests/Auth/LoginRequest.php
$request->validate([
'email' =>
['required', 'email'],
'password' =>
['required'],
]);
Điều này giúp loại bỏ những dữ liệu không hợp lệ ngay từ đầu.
Sau khi Validation thành công, Laravel sẽ:
Tìm User theo Email.
Lấy mật khẩu đã được băm (Hash).
So sánh với mật khẩu người dùng nhập.
Nếu khớp:
Đăng nhập thành công
Nếu không:
Email hoặc mật khẩu không đúng.
Laravel không lưu mật khẩu gốc trong cơ sở dữ liệu.
Khi đăng nhập thành công, Laravel tạo một Session.
Session lưu thông tin người dùng trong suốt thời gian họ sử dụng ứng dụng.
Ví dụ:
Người dùng
↓
Login
↓
Session
↓
Truy cập các trang bảo vệ
Khi đóng phiên làm việc hoặc đăng xuất, Session sẽ bị hủy.
Dữ liệu session thực sự nằm trong database/sessions
F12
└── Application
└── Cookies
└── http://127.0.0.1:8000
Laravel cung cấp:
auth()->check()
Ví dụ:
if (auth()->check()) {
echo 'Đã đăng nhập';
}
Nếu chưa đăng nhập:
echo 'Khách';
$user = auth()->user();
Hoặc:
echo auth()->user()->name;
Email:
echo auth()->user()->email;
ID:
echo auth()->id();
Đây là các phương thức được sử dụng rất thường xuyên trong Laravel.
Ở các bài trước chúng ta dùng:
'user_id' => 1
Bây giờ:
'user_id' => auth()->id()
Ví dụ:
Post::create([
'title' =>
$request->title,
'content' =>
$request->content,
'category_id' =>
$request->category_id,
'image' =>
$image ?? null,
'user_id' =>
auth()->id(),
]);
Bài viết sẽ thuộc về đúng người đang đăng nhập.
Nhờ Relationship đã học:
$posts = auth()
->user()
->posts;
Hoặc:
$posts = Post::where(
'user_id',
auth()->id()
)->get();
Đây là chức năng phổ biến trong Dashboard cá nhân.
Giả sử đã tạo quan hệ One To One:
$profile = auth()
->user()
->profile;
Ví dụ:
echo $profile->phone;
echo $profile->address;
Laravel hỗ trợ:
@auth
Xin chào
{{ auth()->user()->name }}
@endauth
Nếu chưa đăng nhập:
@guest
<a href="{{ route('login') }}">
Login
</a>
@endguest
Nhờ vậy giao diện có thể thay đổi theo trạng thái người dùng.
Laravel Breeze đã tạo sẵn chức năng:
<form method="POST"
action="{{ route('logout') }}">
@csrf
<button>
Logout
</button>
</form>
Sau khi Logout:
Session bị hủy.
Người dùng trở về trạng thái khách.
| Hàm | Ý nghĩa |
|---|---|
| auth()->check() | Kiểm tra đã đăng nhập |
| auth()->user() | Lấy User hiện tại |
| auth()->id() | Lấy ID User |
| @auth | Chỉ hiển thị khi đã đăng nhập |
| @guest | Chỉ hiển thị khi chưa đăng nhập |
Trong bài này chúng ta đã học:
Quy trình Login.
Validation.
Session.
auth()->check().
auth()->user().
auth()->id().
@auth.
@guest.
Tạo Post theo người dùng đang đăng nhập.
Đăng nhập là nền tảng để xây dựng các chức năng như Dashboard, quản lý bài viết cá nhân, phân quyền và bảo vệ dữ liệu.
Đăng nhập bằng tài khoản đã tạo ở bài trước.
Hiển thị tên người dùng trên thanh điều hướng.
Thay toàn bộ user_id = 1 bằng auth()->id().
Hiển thị danh sách bài viết của người đang đăng nhập.
Hiển thị thông tin Profile của người dùng.
Thêm nút Logout vào giao diện.
Dùng @auth và @guest để thay đổi giao diện theo trạng thái đăng nhập.
Ở Bài 23, chúng ta sẽ tìm hiểu Register, cách tạo tài khoản mới, kiểm tra dữ liệu đầu vào, lưu người dùng vào cơ sở dữ liệu và mở rộng quy trình đăng ký để tự động tạo Profile hoặc gán Country mặc định sau khi người dùng đăng ký thành công.
x1
Cập nhật: 2026-07-31T21:00:53.851+07:00
Errors log:
Sau khi cài Breeze file route bị ghi đè (mất dữ liệu)
-> sửa lại thêm middleware (để Breeze xác thực mới cho vào link)
Ví dụ:
use App\Http\Controllers\PostController;
use App\Http\Controllers\CategoryController;
Route::middleware('auth')->group(function () {
Route::resource('posts', PostController::class);
Route::resource('categories', CategoryController::class);
});
và giữ lại:
require __DIR__.'/auth.php';
ở cuối file.
Đến thời điểm này, chúng ta đã có thể xây dựng một Blog CMS với các chức năng như:
Quản lý Post
Category
Tag
Relationship
Form
Validation
Upload File
Tuy nhiên, ứng dụng vẫn còn thiếu một thành phần rất quan trọng:
Hệ thống đăng nhập và quản lý người dùng.
Laravel cung cấp một giải pháp chính thức để xây dựng Authentication hiện đại mang tên Laravel Breeze.
Laravel Breeze là bộ khung Authentication chính thức do đội ngũ Laravel phát triển.
Chỉ với vài lệnh cài đặt, Breeze sẽ tạo sẵn:
Đăng nhập (Login)
Đăng ký (Register)
Quên mật khẩu
Đặt lại mật khẩu
Xác minh Email (tùy chọn)
Giao diện đăng nhập
Middleware xác thực
Controller
Route
View
Người lập trình không cần tự xây dựng từ đầu.
Nếu tự viết Authentication, bạn sẽ phải xử lý:
Mã hóa mật khẩu.
Kiểm tra đăng nhập.
Lưu Session.
Đăng xuất.
CSRF.
Remember Me.
Bảo mật mật khẩu.
Laravel Breeze đã xử lý sẵn toàn bộ các công việc này theo chuẩn của Laravel.
Trước khi cài Breeze, dự án cần:
Laravel đã được cài đặt.
Database đã cấu hình.
Chạy Migration thành công.
Ví dụ:
php artisan migrate
Cài package:
composer require laravel/breeze --dev
Sau khi hoàn tất:
php artisan breeze:install
Laravel sẽ hỏi một số tùy chọn.
Ví dụ:
Which starter kit?
● Blade
○ React
○ Vue
○ API
Trong khóa học này chúng ta chọn:
Blade
để tập trung vào Laravel trước khi học các công nghệ Front-end nâng cao.
Sau khi Breeze tạo mã nguồn:
npm install
Tiếp theo:
npm run build
Trong quá trình phát triển, có thể dùng:
npm run dev
để tự động biên dịch tài nguyên khi có thay đổi.
Breeze sử dụng bảng users có sẵn.
Chạy:
php artisan migrate
Nếu dự án của bạn đã chạy Migration từ các bài trước thì Laravel sẽ chỉ tạo những bảng còn thiếu.
php artisan serve
Mở trình duyệt:
http://127.0.0.1:8000
Trang chủ sẽ xuất hiện thêm:
Login
Register
ở góc trên bên phải.
Nhấn:
Register
Điền:
Name
Password
Confirm Password
Sau khi gửi Form, Laravel sẽ:
Validation.
Mã hóa mật khẩu.
Lưu User vào Database.
Đăng nhập tự động.
Trong Database:
password
$2y$12$...
Laravel sử dụng thuật toán băm để lưu mật khẩu, không lưu mật khẩu gốc.
Điều này giúp tăng tính an toàn nếu cơ sở dữ liệu bị lộ.
Sau khi có tài khoản:
Login
Nhập:
Password
Nếu đúng:
Laravel tạo Session và xác định người dùng đã đăng nhập.
Thay vì:
'user_id' => 1
Từ bây giờ:
'user_id' => auth()->id()
Hoặc:
auth()->user()->id
Đây là cách làm chuẩn trong các ứng dụng Laravel.
Post::create([
'title' =>
$request->title,
'content' =>
$request->content,
'image' =>
$image ?? null,
'category_id' =>
$request->category_id,
'user_id' =>
auth()->id(),
]);
Mỗi bài viết sẽ tự động gắn với người đang đăng nhập.
Blade:
{{ auth()->user()->name }}
Email:
{{ auth()->user()->email }}
Điều này rất hữu ích cho Dashboard hoặc thanh điều hướng views/layous/navigation.blade.php.
Breeze đã tạo sẵn chức năng:
Logout
Sau khi đăng xuất:
Session bị xóa.
Người dùng phải đăng nhập lại để tiếp tục.
Sau khi cài đặt, dự án sẽ có thêm:
app/
Http/
Controllers/
Auth/
resources/
views/
auth/
routes/
auth.php
Ngoài ra còn có:
Middleware.
Validation.
Email Verification (nếu bật).
Các Route xác thực.
Chính thức từ Laravel.
Dễ cài đặt.
Dễ tùy biến.
Mã nguồn rõ ràng.
Phù hợp cho các dự án nhỏ và vừa.
Là nền tảng để học Laravel Authentication.
Trong bài này chúng ta đã học:
Laravel Breeze là gì.
Cài đặt Breeze.
Blade Starter Kit.
Build giao diện.
Đăng ký tài khoản.
Đăng nhập.
auth()->id().
auth()->user().
Đăng xuất.
Từ bài này trở đi, mọi thao tác tạo Post, cập nhật Profile hay quản lý dữ liệu đều sẽ gắn với người dùng đang đăng nhập, thay vì sử dụng giá trị user_id cố định như ở các bài trước.
Cài đặt Laravel Breeze.
Tạo một tài khoản mới.
Đăng nhập vào hệ thống.
Thay user_id = 1 bằng auth()->id().
Hiển thị tên người dùng trên giao diện.
Tạo một Post và kiểm tra user_id trong cơ sở dữ liệu.
Đăng xuất khỏi hệ thống.
Ở Bài 22, chúng ta sẽ tìm hiểu chi tiết cách Login hoạt động trong Laravel, bao gồm Session, Guard, Middleware và quy trình xác thực người dùng sau khi đăng nhập.
x1
Cập nhật: 2026-07-31T20:38:13.239+07:00
"Trước khi có Promise và Async/Await, Callback chính là cách Node.js xử lý các tác vụ bất đồng bộ. Hiểu Callback sẽ giúp bạn hiểu vì sao JavaScript hiện đại được thiết kế như ngày nay."
Sau bài này bạn sẽ:
Hiểu Callback là gì.
Phân biệt Callback đồng bộ và bất đồng bộ.
Biết cách truyền Callback vào hàm.
Hiểu Callback Hell là gì.
Biết khi nào nên dùng Callback.
Hiểu vì sao Promise và Async/Await ra đời.
Callback là một hàm được truyền vào làm tham số của một hàm khác để được gọi lại (call back) khi cần.
Ví dụ đơn giản:
function hello(callback) {
console.log("Xin chào");
callback();
}
hello(() => {
console.log("Node.js");
});
Kết quả
Xin chào
Node.js
Ở đây:
hello() là hàm chính.
Hàm mũi tên được truyền vào chính là Callback.
Giả sử bạn cần:
Đọc file.
Sau khi đọc xong mới xử lý dữ liệu.
Không thể:
readFile();
xuLy();
vì chưa chắc file đã đọc xong.
Thay vào đó:
readFile(() => {
xuLy();
});
Chỉ khi hoàn thành, Callback mới được gọi.
Ví dụ
function tinhTong(a, b, callback) {
const tong = a + b;
callback(tong);
}
console.log("Bắt đầu");
tinhTong(5, 8, (kq) => {
console.log(kq);
});
console.log("Kết thúc");
↓
13
Ở đây Callback được gọi ngay lập tức, nên đây là Callback đồng bộ (Synchronous Callback).
Ví dụ
function tinhTong(a, b, callback) {
const tong = a + b;
setTimeout(() => {
callback(tong);
}, 2000);
}
console.log("Bắt đầu");
tinhTong(5, 8, (kq) => {
console.log(kq);
});
console.log("Kết thúc");
↓
Đang chờ...
(sau 2 giây)
Đã xong
Callback chỉ chạy sau khi hết thời gian chờ.
Ví dụ
import fs from "node:fs";
fs.readFile(
"data.txt",
"utf8",
(err, data) => {
if (err) {
console.log(err);
return;
}
console.log(data);
}
);
↓
Xin chào Node.js
Đây là kiểu Callback rất phổ biến trong Node.js.
Phần lớn API của Node.js sử dụng quy ước:
(err, data)
Ví dụ
fs.readFile(
"abc.txt",
"utf8",
(err, data) => {
if (err) {
console.log(err);
return;
}
console.log(data);
}
);
Nếu lỗi:
ENOENT
Nếu thành công:
Nội dung file
Tham số đầu tiên luôn là Error.
Đây được gọi là:
Error-first Callback
Ví dụ
function login(callback) {
console.log("Đang đăng nhập...");
setTimeout(() => {
console.log("Đăng nhập thành công");
callback();
}, 2000);
}
function loadProfile(callback) {
console.log("Đang tải thông tin...");
setTimeout(() => {
console.log("Đã tải Profile");
callback();
}, 1500);
}
function loadOrders(callback) {
console.log("Đang tải đơn hàng...");
setTimeout(() => {
console.log("Đã tải đơn hàng");
callback();
}, 1000);
}
// Callback nhiều cấp
login(() => {
loadProfile(() => {
loadOrders(() => {
console.log("Hoàn thành");
});
});
});
Nhìn vẫn còn đơn giản.
Nhưng nếu dự án lớn:
login(() => {
profile(() => {
avatar(() => {
order(() => {
payment(() => {
history(() => {
message(() => {
notify(() => {
});
});
});
});
});
});
});
});
Code bắt đầu lệch sang bên phải rất nhiều.
Đây là hiện tượng nhiều Callback lồng nhau.
login()
↓
profile()
↓
order()
↓
payment()
↓
email()
↓
log()
↓
notification()
Hay còn được gọi vui là:
Pyramid of Doom (Kim tự tháp địa ngục).
Khó đọc.
Khó bảo trì.
Debug khó.
Dễ quên xử lý lỗi.
Khó tái sử dụng.
Đó là lý do Promise và Async/Await trở thành lựa chọn phổ biến trong JavaScript hiện đại.
Không.
Nhiều API của Node.js vẫn dùng Callback vì:
Đơn giản.
Hiệu năng tốt.
Không cần tạo Promise.
Ví dụ
fs.readFile(
"data.txt",
"utf8",
callback
);
Hoặc
import http from "node:http";
const server = http.createServer((req, res) => {
res.end("Hello Node.js");
});
server.listen(3000, () => {
console.log("Server running");
});
Hai khái niệm này thường bị nhầm.
Callback
function download(callback) {
console.log("Đang tải...");
setTimeout(() => {
console.log("Tải xong");
callback();
}, 3000);
}
download(() => {
console.log("Xong");
});
Một Callback.
Event
import EventEmitter from "node:events";
const event = new EventEmitter();
event.on("message", () => {
console.log("Có tin nhắn");
});
event.emit("message");
Có thể có nhiều Listener.
Callback
readFile((err, data) => {
});
Promise
import fs from "node:fs/promises";
fs.readFile("data.txt", "utf8")
.then((data) => {
console.log(data);
})
.catch((err) => {
console.log(err);
});
Promise giúp code phẳng hơn và dễ kết hợp nhiều tác vụ bất đồng bộ.
Promise
await readFile();
↓
Code nhìn gần giống:
const data =
docFile();
Dễ đọc hơn rất nhiều.
Ví dụ:
import fs from "node:fs/promises"; fs.readFile("data.txt", "utf8") .then((data) => { console.log(data); }) .catch((err) => { console.log(err); });
import fs from "node:fs/promises"; async function docFile() { try { const data = await fs.readFile("data.txt", "utf8"); return data; } catch (err) { console.log(err); } } async function main() { const data = await docFile(); console.log(data); } main();
Giả sử data.txt có nội dung:
Giả sử tải thông tin người dùng.
function getUser(callback){
setTimeout(()=>{
callback({
name:"An"
});
},1000);
}
getUser((user)=>{
console.log(user.name);
});
↓
An
Mặc dù mất 1 giây để lấy dữ liệu, chương trình vẫn không bị chặn.
Gọi hàm
↓
Thực hiện công việc
↓
Hoàn thành
↓
Gọi Callback
Đây chính là cơ chế nền tảng của rất nhiều API bất đồng bộ trong Node.js.
Sai
fs.readFile(
"data.txt",
(err,data)=>{
console.log(data);
}
);
Nên
if(err){
return;
}
trước khi sử dụng data.
A(()=>{
B(()=>{
C(()=>{
D(()=>{
});
});
});
});
Nên cân nhắc Promise hoặc Async/Await khi chuỗi xử lý trở nên phức tạp.
callback();
callback();
Có thể gây lỗi logic hoặc khiến kết quả bị xử lý lặp lại ngoài ý muốn.
function test(cb){
}
Nếu không gọi:
cb();
phần mã đang chờ Callback sẽ không bao giờ được thực thi.
Phù hợp với:
API cũ.
Event.
Timer.
Stream.
HTTP Server.
Một số module lõi của Node.js.
Trong các dự án mới, Callback vẫn xuất hiện nhưng thường được thay thế hoặc bọc bởi Promise khi chuỗi xử lý trở nên phức tạp.
| Khái niệm | Ý nghĩa |
|---|---|
| Callback | Hàm truyền vào hàm khác |
| Sync Callback | Chạy ngay |
| Async Callback | Chạy sau |
| Error-first Callback | (err, data) |
| Callback Hell | Callback lồng nhau |
| Promise | Giải quyết Callback Hell |
| Async/Await | Cú pháp hiện đại cho Promise |
Callback là nền tảng của lập trình bất đồng bộ trong Node.js. Mặc dù ngày nay Promise và Async/Await được sử dụng phổ biến hơn, việc hiểu Callback vẫn rất quan trọng vì nhiều API cốt lõi của Node.js và không ít thư viện vẫn hỗ trợ hoặc sử dụng mô hình này.
Nắm vững Callback sẽ giúp bạn hiểu rõ cách hoạt động của Event Loop, Promise và Async/Await trong các bài học tiếp theo.
👉 Bài 12 — Promise
Bạn sẽ học:
Promise là gì.
Ba trạng thái của Promise.
resolve() và reject().
then(), catch() và finally().
Vì sao Promise giúp mã nguồn dễ đọc, dễ bảo trì và giải quyết hiệu quả Callback Hell.
x1
quay về MỤC LỤC
Cập nhật: 2026-08-01T20:13:20.672+07:00
Sau khi hoàn thành phần HTML5 Enterprise, chúng ta đã có thể xây dựng cấu trúc website chuẩn. Tuy nhiên, nếu mở cùng một trang trên Chrome, Firefox, Edge hoặc Safari, bạn sẽ thấy giao diện có thể không giống nhau.
Nguyên nhân là mỗi trình duyệt đều có một bộ CSS mặc định (User Agent Stylesheet) riêng.
Để giải quyết vấn đề này, lập trình viên thường bắt đầu dự án bằng CSS Reset.
Sau bài này bạn sẽ:
Hiểu CSS Reset là gì.
Biết vì sao cần Reset CSS.
Phân biệt CSS Reset và Normalize CSS.
Tự xây dựng một bộ Reset hiện đại.
Chuẩn bị nền tảng cho Responsive Design và Design System.
CSS Reset là tập hợp các quy tắc CSS giúp đưa tất cả các trình duyệt về một điểm xuất phát giống nhau.
Ví dụ:
Chrome có thể đặt:
body {
margin: 8px;
}
Trong khi trình duyệt khác có thể xử lý khoảng cách hoặc kích thước mặc định khác nhau.
Điều này khiến cùng một mã HTML hiển thị không đồng nhất.
Nếu không Reset CSS:
Margin khác nhau.
Padding khác nhau.
Font mặc định khác nhau.
Danh sách (ul, ol) hiển thị khác nhau.
Heading có kích thước khác nhau.
Form hiển thị không đồng nhất.
Điều này làm quá trình thiết kế giao diện trở nên khó kiểm soát.
Không Reset:
<h1>Enterprise Web</h1>
<p>Hello World</p>
Bạn sẽ thấy khoảng cách giữa các phần tử phụ thuộc vào trình duyệt.
Sau khi Reset, khoảng cách sẽ do chính bạn quyết định.
Trước đây nhiều lập trình viên sử dụng:
*{
margin:0;
padding:0;
}
Đây là cách đơn giản nhất.
Tuy nhiên, nó chưa đủ cho các dự án hiện đại.
*{
margin:0;
padding:0;
}
Dấu * nghĩa là:
Áp dụng cho tất cả phần tử HTML.
Ví dụ:
div
h1
p
ul
img
button
đều bị Reset.
Đây là dòng gần như luôn có trong mọi dự án.
*{
box-sizing:border-box;
}
width = content
+
padding
+
border
Tổng chiều rộng sẽ lớn hơn giá trị bạn khai báo.
width
=
content
+
padding
+
border
Chiều rộng luôn đúng bằng giá trị khai báo.
Đây là cách tính được sử dụng phổ biến trong các dự án hiện đại.
body{
margin:0;
}
Loại bỏ khoảng trắng mặc định của trình duyệt.
img{
display:block;
max-width:100%;
}
Ý nghĩa:
display:block
→ loại bỏ khoảng trắng dưới ảnh.
max-width:100%
→ ảnh không vượt quá phần tử cha.
Đây là nền tảng cho Responsive Images.
ul,
ol{
list-style:none;
}
Loại bỏ:
dấu chấm
số thứ tự
Bạn có thể tự thiết kế giao diện danh sách sau này.
a{
text-decoration:none;
color:inherit;
}
Giúp:
bỏ gạch chân
kế thừa màu chữ từ phần tử cha
Sau đó bạn có thể tùy chỉnh màu sắc theo Design System.
button{
border:none;
background:none;
cursor:pointer;
}
Giúp nút có giao diện thống nhất hơn giữa các trình duyệt.
input,
textarea,
select,
button{
font:inherit;
}
Giúp các thành phần Form sử dụng cùng font với website.
table{
border-collapse:collapse;
}
Loại bỏ khoảng cách giữa các ô trong bảng.
Đây là bộ Reset đơn giản nhưng phù hợp với hầu hết dự án.
*,
*::before,
*::after{
box-sizing:border-box;
}
*{
margin:0;
padding:0;
}
body{
line-height:1.5;
}
img{
display:block;
max-width:100%;
}
ul,
ol{
list-style:none;
}
a{
color:inherit;
text-decoration:none;
}
input,
button,
textarea,
select{
font:inherit;
}
table{
border-collapse:collapse;
}
Đây là một điểm khởi đầu tốt cho các dự án doanh nghiệp.
Mục tiêu:
Xóa gần như toàn bộ CSS mặc định của trình duyệt.
Ưu điểm:
Đồng nhất.
Dễ kiểm soát.
Phù hợp Design System.
Nhược điểm:
Phải tự định nghĩa lại nhiều kiểu hiển thị.
Mục tiêu:
Giữ lại những thiết lập mặc định hữu ích và chỉ sửa những điểm khác biệt giữa các trình duyệt.
Ưu điểm:
Ít phải viết lại CSS.
Phù hợp nhiều website nội dung.
Nhược điểm:
Một số kiểu mặc định vẫn còn tồn tại.
Nên dùng khi xây dựng:
Landing Page
Website doanh nghiệp
Dashboard
CRM
ERP
SaaS
E-Commerce
Design System
Component Library
Đây cũng là lựa chọn phổ biến trong các dự án Front-End hiện đại.
❌ Chỉ Reset bằng:
*{
margin:0;
padding:0;
}
mà quên box-sizing.
❌ Không Reset ảnh khiến hình vượt khỏi khung.
❌ Quên font: inherit cho Form.
❌ Reset quá mạnh rồi không định nghĩa lại kiểu hiển thị cần thiết.
❌ Xóa toàn bộ Focus của trình duyệt mà không thay thế bằng kiểu Focus mới, làm giảm khả năng Accessibility.
Tạo tệp:
reset.css
Bao gồm:
Reset Margin
Reset Padding
Border Box
Reset Image
Reset List
Reset Link
Reset Button
Reset Form
Reset Table
Sau đó nhúng vào một trang HTML và kiểm tra sự khác biệt trước và sau khi áp dụng.
CSS Reset là bước đầu tiên trong hầu hết các dự án Front-End chuyên nghiệp. Nó giúp loại bỏ sự khác biệt giữa các trình duyệt, tạo nền tảng ổn định để xây dựng giao diện hiện đại và dễ bảo trì.
Trong bài tiếp theo, chúng ta sẽ tìm hiểu CSS Variables, một tính năng mạnh mẽ giúp quản lý màu sắc, khoảng cách và toàn bộ Design System một cách linh hoạt.
✅ Hiểu CSS Reset là gì.
✅ Biết nguyên nhân khiến giao diện khác nhau giữa các trình duyệt.
✅ Sử dụng box-sizing: border-box.
✅ Reset hình ảnh, danh sách, liên kết, Form và bảng.
✅ Phân biệt CSS Reset và Normalize CSS.
✅ Xây dựng bộ CSS Reset hiện đại cho dự án Enterprise.
➡️ Bài tiếp theo: Bài 06 — CSS Variables: Xây dựng Design System linh hoạt với Custom Properties.
Cập nhật: 2026-07-27T07:20:00.189+07:00
Một website chuyên nghiệp không chỉ đẹp và nhanh, mà còn phải dễ sử dụng đối với tất cả mọi người, bao gồm:
Người khiếm thị.
Người khiếm thính.
Người lớn tuổi.
Người gặp khó khăn về vận động.
Người chỉ sử dụng bàn phím.
Người dùng thiết bị di động.
Đó chính là mục tiêu của Accessibility (A11y).
HTML5 đã hỗ trợ rất nhiều tính năng Accessibility, và khi kết hợp với ARIA (Accessible Rich Internet Applications), chúng ta có thể xây dựng các website thân thiện với mọi người dùng.
Sau bài này bạn sẽ:
Hiểu Accessibility là gì.
Biết vai trò của ARIA.
Viết HTML thân thiện với Screen Reader.
Hỗ trợ điều hướng bằng bàn phím.
Xây dựng Form dễ sử dụng hơn.
Tránh các lỗi Accessibility phổ biến.
Accessibility (A11y) là việc thiết kế website để mọi người đều có thể sử dụng, kể cả người khuyết tật hoặc người có nhu cầu hỗ trợ đặc biệt.
Một website có Accessibility tốt sẽ:
Dễ đọc.
Dễ điều hướng.
Dễ tương tác.
Hoạt động tốt với các công nghệ hỗ trợ.
Bạn thường thấy ký hiệu:
A11y
Nó là cách viết tắt của:
Accessibility
Có:
A
11 ký tự ở giữa
y
→ A11y
Không phải ai cũng sử dụng chuột.
Nhiều người chỉ dùng:
bàn phím
Screen Reader
thiết bị hỗ trợ
điều khiển bằng giọng nói
Website cần hỗ trợ tất cả các trường hợp này.
Google cũng thích:
Semantic HTML
Heading rõ ràng
Alt hình ảnh
Label đầy đủ
Accessibility và SEO có rất nhiều điểm chung.
Rất nhiều doanh nghiệp lớn yêu cầu website phải đạt tiêu chuẩn WCAG (Web Content Accessibility Guidelines).
Đây là một kỹ năng quan trọng khi làm việc trong các dự án quốc tế.
Accessibility bắt đầu từ việc sử dụng đúng thẻ HTML.
Ví dụ:
<header>
<nav>
<main>
<section>
<footer>
Thay vì:
<div>
<div>
<div>
Screen Reader sẽ hiểu cấu trúc của trang tốt hơn khi sử dụng các thẻ Semantic.
Sai:
<input
type="email"
placeholder="Email">
Đúng:
<label for="email">
Email
</label>
<input
id="email"
type="email">
Screen Reader sẽ đọc đúng tên trường nhập liệu.
Sai:
<img src="logo.png">
Đúng:
<img
src="logo.png"
alt="Logo Lớp Học Vi Tính">
Nếu hình ảnh mang tính trang trí và không chứa thông tin quan trọng, bạn có thể sử dụng:
<img
src="line.png"
alt="">
Điều này giúp Screen Reader bỏ qua hình ảnh không cần thiết.
Người dùng cần có thể sử dụng phím:
Tab
Shift + Tab
Enter
Space
để thao tác với website.
Các phần tử như:
<button>
<a>
<input>
<select>
<textarea>
đã hỗ trợ điều hướng bằng bàn phím theo mặc định.
Khi sử dụng phím Tab, trình duyệt sẽ hiển thị vùng được chọn (Focus).
Không nên xóa Focus bằng CSS:
button:focus {
outline: none;
}
Điều này khiến người dùng bàn phím khó xác định vị trí hiện tại.
Nếu muốn tùy chỉnh, hãy thay bằng một kiểu hiển thị khác rõ ràng hơn.
ARIA (Accessible Rich Internet Applications) là tập hợp các thuộc tính giúp tăng khả năng truy cập cho các thành phần giao diện phức tạp.
Ví dụ:
<button
aria-label="Đóng cửa sổ">
×
</button>
Screen Reader sẽ đọc:
Đóng cửa sổ
thay vì chỉ đọc ký tự "×".
<input
type="search"
aria-label="Tìm kiếm khóa học">
Rất hữu ích khi không có label hiển thị trên giao diện.
Nếu một biểu tượng chỉ mang tính trang trí:
<i
class="bi bi-star"
aria-hidden="true">
</i>
Screen Reader sẽ bỏ qua biểu tượng này.
Dùng cho menu hoặc Accordion.
<button
aria-expanded="false">
Menu
</button>
Khi menu mở:
<button
aria-expanded="true">
Menu
</button>
Đánh dấu trang hiện tại.
<a
href="/blog"
aria-current="page">
Blog
</a>
Screen Reader sẽ biết đây là trang đang được mở.
Đôi khi cần bổ sung vai trò cho các thành phần tùy chỉnh.
Ví dụ:
<div
role="button">
Đăng nhập
</div>
Tuy nhiên, nếu có thể, hãy sử dụng:
<button>
Đăng nhập
</button>
Thẻ HTML gốc luôn được ưu tiên hơn.
Website lớn thường có liên kết:
<a
href="#main">
Bỏ qua menu
</a>
Giúp người dùng bàn phím đi thẳng đến nội dung chính.
Trong trang:
<main id="main">
...
</main>
Không nên sử dụng:
chữ xám nhạt trên nền trắng
chữ vàng trên nền trắng
chữ xanh nhạt trên nền xanh
Nội dung cần có độ tương phản đủ cao để dễ đọc.
<header>
<h1>
Enterprise Web Layout Master
</h1>
</header>
<nav>
<a href="#main">
Bỏ qua menu
</a>
</nav>
<main id="main">
<form>
<label for="email">
Email
</label>
<input
id="email"
type="email"
required>
<button>
Đăng nhập
</button>
</form>
</main>
Đây là một ví dụ đơn giản nhưng đã áp dụng nhiều nguyên tắc Accessibility.
❌ Chỉ dùng placeholder mà không có label.
❌ Thiếu alt cho hình ảnh.
❌ Sử dụng <div> để làm nút bấm thay vì <button>.
❌ Xóa hiệu ứng Focus bằng CSS.
❌ Không hỗ trợ điều hướng bằng bàn phím.
❌ Dùng màu chữ và màu nền có độ tương phản thấp.
❌ Lạm dụng ARIA khi HTML gốc đã đủ đáp ứng.
Tạo một trang Đăng nhập gồm:
Tiêu đề <h1>.
Form đăng nhập.
Label cho Email và Password.
Nút Đăng nhập.
Liên kết "Bỏ qua menu".
Một hình ảnh logo có thuộc tính alt.
Một nút đóng sử dụng aria-label.
Yêu cầu:
Sử dụng Semantic HTML.
Hỗ trợ điều hướng bằng bàn phím.
Không dùng div thay cho button.
Chỉ sử dụng ARIA khi thực sự cần.
Accessibility không phải là tính năng dành riêng cho người khuyết tật mà là cách xây dựng website để mọi người đều có thể sử dụng dễ dàng. Khi kết hợp Semantic HTML, Form chuẩn, ARIA và khả năng điều hướng bằng bàn phím, bạn sẽ tạo ra những website chuyên nghiệp, thân thiện với người dùng và đáp ứng các tiêu chuẩn hiện đại.
Đây cũng là nền tảng quan trọng cho các dự án doanh nghiệp, thương mại điện tử và hệ thống quản trị trong tương lai.
✅ Hiểu khái niệm Accessibility (A11y).
✅ Biết vai trò của ARIA trong HTML.
✅ Sử dụng label, alt và Semantic HTML đúng cách.
✅ Hỗ trợ điều hướng bằng bàn phím và Focus.
✅ Áp dụng các thuộc tính aria-label, aria-hidden, aria-expanded, aria-current.
✅ Tránh các lỗi Accessibility phổ biến.
✅ Sẵn sàng xây dựng giao diện đạt tiêu chuẩn doanh nghiệp.
Sau 4 bài học đầu tiên, bạn đã xây dựng được nền móng vững chắc về HTML5 theo tiêu chuẩn Enterprise:
✅ Semantic HTML
✅ Form HTML5
✅ SEO Friendly HTML
✅ Accessibility & ARIA
Đây là những kiến thức cốt lõi sẽ được sử dụng xuyên suốt các phần tiếp theo như CSS Foundation, Responsive Design, Bootstrap 5, Tailwind CSS và Dashboard Enterprise.
➡️ Bài tiếp theo: Bài 05 — CSS Reset: Đồng bộ giao diện trên mọi trình duyệt.
Cập nhật: 2026-07-26T11:14:36.274+07:00
"Nếu phải đọc một file 20 GB, bạn sẽ tải toàn bộ vào RAM hay đọc từng phần nhỏ? Câu trả lời chính là Buffer và Stream - hai nền tảng giúp Node.js xử lý dữ liệu cực kỳ hiệu quả."
Sau bài này bạn sẽ:
Hiểu Buffer là gì.
Hiểu Stream là gì.
Biết các loại Stream trong Node.js.
Thành thạo Readable Stream và Writable Stream.
Biết sử dụng pipe().
Hiểu vì sao Stream giúp Node.js xử lý file lớn với hiệu năng cao.
Máy tính chỉ hiểu dữ liệu dưới dạng byte.
Node.js sử dụng Buffer để lưu trữ dữ liệu nhị phân (Binary Data).
Ví dụ:
File ảnh
Video
Âm thanh
File ZIP
Dữ liệu truyền qua mạng
đều được xử lý bằng Buffer.
const buf =
Buffer.from("Hello");
In ra
console.log(buf);
↓
<Buffer 48 65 6c 6c 6f>
Đây chính là mã ASCII của chuỗi.
console.log(
buf.toString()
);
↓
Hello
const buf =
Buffer.from([
65,
66,
67
]);
console.log(
buf.toString()
);
↓
ABC
Tạo Buffer có kích thước cố định.
const buf =
Buffer.alloc(8);
console.log(buf);
↓
<Buffer 00 00 00 00 00 00 00 00>
Đếm số byte.
console.log(
Buffer.byteLength(
"Node.js"
)
);
↓
7
Ví dụ với Unicode
console.log(
Buffer.byteLength(
"Xin chào"
)
);
Kết quả sẽ lớn hơn số ký tự vì một số ký tự Unicode chiếm nhiều hơn 1 byte.
Thay vì:
Đọc toàn bộ file
↓
Đưa vào RAM
↓
Xử lý
Node.js có thể:
Đọc từng phần nhỏ
↓
Xử lý
↓
Đọc tiếp
Đó chính là Stream.
File
movie.mp4
Dung lượng
20 GB
Nếu đọc bằng:
readFile()
↓
RAM phải chứa toàn bộ file.
Nếu dùng Stream
64 KB
↓
64 KB
↓
64 KB
↓
...
Node chỉ giữ một phần dữ liệu trong bộ nhớ tại mỗi thời điểm.
Tiết kiệm RAM.
Tăng tốc xử lý.
Phù hợp file lớn.
Có thể xử lý dữ liệu ngay khi nhận được.
Được sử dụng trong HTTP, Upload và Download.
Node.js có bốn loại Stream.
| Loại | Ý nghĩa |
|---|---|
| Readable | Đọc dữ liệu |
| Writable | Ghi dữ liệu |
| Duplex | Đọc và ghi |
| Transform | Đọc, biến đổi rồi ghi |
Đọc file.
import fs from "node:fs";
const stream =
fs.createReadStream(
"data.txt", {
highWaterMark: 5
}
);
stream.on(
"data",
(chunk)=>{
console.log(chunk);
});
↓
<Buffer ...>
Mỗi lần sẽ nhận một chunk (khối dữ liệu).
stream.setEncoding(
"utf8"
);
stream.on(
"data",
(chunk)=>{
console.log(chunk);
});
↓
Xin
chào
Node.js
Tùy kích thước file, dữ liệu có thể được chia thành nhiều chunk.
stream.on(
"end",
()=>{
console.log(
"Hoàn thành"
);
});
↓
Hoàn thành
stream.on(
"error",
(err)=>{
console.log(err);
});
Ví dụ:
ENOENT
Tạo Stream ghi.
import fs from "node:fs";
const stream =
fs.createWriteStream(
"log.txt"
);
Ghi
stream.write(
"Hello\n"
);
stream.write(
"Node.js\n"
);
stream.end();
Nội dung file
Hello
Node.js
Kết thúc Stream.
stream.end();
Sau khi gọi end(), bạn không nên tiếp tục ghi thêm dữ liệu vào Stream đó.
Đây là phương pháp phổ biến nhất.
Ví dụ sao chép file (phải có file video.mp4 trong thư mục gốc).
import fs from "node:fs";
const read =
fs.createReadStream(
"video.mp4"
);
const write =
fs.createWriteStream(
"copy.mp4"
);
read.pipe(write);
Sơ đồ
video.mp4
↓
Readable
↓
pipe()
↓
Writable
↓
copy.mp4
Không cần đọc toàn bộ file vào RAM.
Xác định kích thước mỗi chunk.
const stream =
fs.createReadStream(
"data.txt",
{
highWaterMark:
1024
}
);
↓
1 KB/chunk
Nếu không cấu hình, Node.js sẽ sử dụng giá trị mặc định tùy theo loại Stream.
Đếm số byte đã đọc.
let total = 0;
stream.on(
"data",
(chunk)=>{
total +=
chunk.length;
});
stream.on(
"end",
()=>{
console.log(
total
);
});
↓
102400
Bạn sẽ gặp Stream ở rất nhiều nơi.
HTTP
Express
Upload
Download
Video
Audio
ZIP
Cloud Storage
Database Export
| Buffer | Stream |
|---|---|
| Lưu dữ liệu trong RAM | Đọc/Ghi từng phần |
| Phù hợp dữ liệu nhỏ | Phù hợp dữ liệu lớn |
| Truy cập toàn bộ dữ liệu ngay | Xử lý theo từng chunk |
| Có thể tốn nhiều RAM | Tiết kiệm bộ nhớ |
Buffer phù hợp khi:
Chuỗi nhỏ.
Mã hóa dữ liệu.
Xử lý Binary.
Chuyển đổi Encoding.
Làm việc với các gói dữ liệu nhỏ.
Nên dùng:
Upload ảnh.
Upload video.
Download file.
HTTP Server.
Sao chép file lớn.
Xử lý log.
Truyền dữ liệu thời gian thực.
await fs.readFile(
"movie.mp4"
);
Có thể tiêu tốn rất nhiều RAM hoặc làm ứng dụng phản hồi chậm.
Hãy cân nhắc sử dụng createReadStream() với các tệp lớn.
stream.on(
"error",
(err)=>{
console.log(err);
});
Nếu không lắng nghe sự kiện error, chương trình có thể dừng khi gặp lỗi đọc hoặc ghi file.
stream.write(
"Hello"
);
Nếu không gọi:
stream.end();
Stream ghi có thể chưa đóng đúng cách và dữ liệu có thể chưa được ghi hoàn tất.
console.log(
chunk
);
↓
<Buffer ...>
Muốn đọc dạng văn bản:
chunk.toString();
hoặc:
stream.setEncoding(
"utf8"
);
| API | Ý nghĩa |
|---|---|
Buffer.from() | Tạo Buffer |
Buffer.alloc() | Tạo Buffer rỗng |
Buffer.byteLength() | Đếm số byte |
createReadStream() | Đọc Stream |
createWriteStream() | Ghi Stream |
pipe() | Nối hai Stream |
write() | Ghi dữ liệu |
end() | Kết thúc Stream |
Trong bài học này, bạn đã tìm hiểu hai khái niệm rất quan trọng của Node.js là Buffer và Stream. Buffer dùng để lưu trữ dữ liệu nhị phân trong bộ nhớ, còn Stream cho phép đọc và ghi dữ liệu theo từng phần nhỏ, giúp tiết kiệm RAM và xử lý hiệu quả các tệp có dung lượng lớn.
Đây là nền tảng cho rất nhiều tính năng trong Node.js như HTTP Server, Upload/Download, xử lý video, âm thanh, lưu trữ đám mây và truyền dữ liệu thời gian thực.
👉 Bài 11 — Callback
Bạn sẽ học:
Callback là gì.
Callback hoạt động như thế nào.
Callback đồng bộ và bất đồng bộ.
Callback Hell là gì.
Vì sao Promise và Async/Await ra đời để giải quyết những hạn chế của Callback.
x1
quay về MỤC LỤC
Cập nhật: 2026-07-26T11:14:45.801+07:00
Ở bài trước, chúng ta đã học cách Validation dữ liệu trước khi lưu vào cơ sở dữ liệu.
Tuy nhiên, một Blog hoặc CMS không chỉ có văn bản mà còn cần hình ảnh minh họa cho bài viết, ảnh đại diện người dùng hoặc các tệp đính kèm.
Laravel cung cấp hệ thống Storage giúp việc tải lên (Upload) và quản lý tệp trở nên đơn giản, an toàn và dễ mở rộng.
Trong bài này, chúng ta sẽ xây dựng chức năng tải ảnh đại diện cho Post.
Upload File là quá trình người dùng gửi tệp từ máy tính lên máy chủ.
Ví dụ:
Ảnh bài viết.
Avatar người dùng.
File PDF.
Video.
Tài liệu Word.
Laravel sẽ tiếp nhận tệp thông qua đối tượng Request.
Trong bảng posts, thêm cột lưu đường dẫn ảnh.
Tạo Migration:
php artisan make:migration add_image_to_posts_table
Migration:
Schema::table('posts', function (Blueprint $table) {
$table->string('image')
->nullable()
->after('content');
});
Chạy Migration:
php artisan migrate
class Post extends Model
{
protected $fillable = [
'title',
'content',
'category_id',
'user_id',
'image'
];
...
create Blade:
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css"
rel="stylesheet">
<form
action="{{ route('posts.store') }}"
method="post"
enctype="multipart/form-data">
@csrf
<input
type="text"
name="title"
value="{{ old('title') }}"
placeholder="Tiêu đề"
id="">
@error('title')
<div class="text-danger">
{{ $message }}
</div>
@enderror
<br><br>
<textarea
name="content"
placeholder="Nội dung" id="" rows="6">{{ old('content') }}
</textarea>
@error('content')
<div class="text-danger">
{{ $message }}
</div>
@enderror
<br><br>
<select name="category_id" >
@foreach($categories as $category)
<option value="{{ $category->id }}">
{{ $category->name }}
</option>
@endforeach
</select>
<br><br>
<select
name="tags[]"
multiple>
@foreach($tags as $tag)
<option value="{{ $tag->id }}">
{{ $tag->name }}
</option>
@endforeach
</select>
<br><br>
<input
type="file"
name="image"
id="image">
<br><br>
<img
id="preview"
src=""
style="display:none;max-width:300px;">
<br><br>
<button type="submit">
Lưu bài viết
</button>
</form>
<script>
const input = document.getElementById('image');
const preview = document.getElementById('preview');
input.addEventListener('change', function () {
const file = this.files[0];
if (!file) {
preview.style.display = 'none';
preview.src = '';
return;
}
preview.src = URL.createObjectURL(file);
preview.style.display = 'block';
});
</script>
Thuộc tính:
enctype="multipart/form-data"
là bắt buộc khi gửi tệp.
Kiểm tra hình ảnh:
$request->validate([
'title' =>
'required|max:255',
'content' =>
'required|min:20',
'image' =>
'nullable|image|mimes:jpg,jpeg,png,webp|max:2048',
]);
Ý nghĩa:
image: phải là ảnh.
mimes: chỉ cho phép các định dạng.
max:2048: tối đa 2 MB.
//dd($request->hasFile('image'));
//dd($request->file('image'));
if ($request->hasFile('image')) {
//
}
Nếu người dùng chọn ảnh thì mới xử lý.
$image = $request
->file('image')
->store(
'posts',
'public'
);
Laravel sẽ lưu ảnh vào:
storage/app/public/posts
Biến $image sẽ chứa đường dẫn, ví dụ:
posts/aBcD12345.jpg
$post = Post::create([
'title' => $request->title,
'content' => $request->content,
'image' => $image ?? null,
'category_id' => $request->category_id,
'user_id' => 1,
]);
$post->tags()->sync(
$request->tags ?? []
);
return redirect()->route('posts.show', $post);
Đường dẫn ảnh sẽ được lưu vào cột image.
show.blade.php
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css"
rel="stylesheet">
<div class="container py-4">
<h1 class="mb-4">
Chi tiết bài viết
</h1>
<div class="card">
<div class="card-body">
<h2 class="card-title">
{{ $post->title }}
</h2>
<hr>
<p>
<strong>Danh mục:</strong>
{{ $post->category->name ?? 'Chưa có' }}
</p>
<p>
<strong>Nội dung:</strong>
</p>
<p>
{!! nl2br(e($post->content)) !!}
</p>
@if($post->image)
<hr>
<h5>Hình ảnh</h5>
<img
src="{{ asset('storage/' . $post->image) }}"
alt="{{ $post->title }}"
class="img-fluid rounded">
@endif
@if($post->tags->count())
<hr>
<h5>Tags</h5>
@foreach($post->tags as $tag)
<span class="badge bg-primary">
{{ $tag->name }}
</span>
@endforeach
@endif
<hr>
<p class="text-muted mb-1">
Ngày tạo:
{{ $post->created_at }}
</p>
<p class="text-muted">
Cập nhật:
{{ $post->updated_at }}
</p>
<a
href="{{ route('posts.index') }}"
class="btn btn-secondary">
Quay lại
</a>
<a
href="{{ route('posts.edit', $post) }}"
class="btn btn-warning">
Sửa
</a>
</div>
</div>
</div>Laravel không hiển thị trực tiếp thư mục storage.
Chạy:
php artisan storage:link
Laravel sẽ tạo:
public/storage
trỏ tới:
storage/app/public
Chỉ cần chạy một lần cho mỗi dự án.
Blade:
@if($post->image)
<img
src="{{ asset('storage/' . $post->image) }}"
alt="{{ $post->title }}"
width="300">
@endif
Laravel sẽ hiển thị đúng đường dẫn ảnh đã lưu.
Khi chỉnh sửa bài viết:
if ($request->hasFile('image')) {
$image = $request
->file('image')
->store(
'posts',
'public'
);
$post->image = $image;
}
$post->save();
Ảnh mới sẽ thay thế đường dẫn cũ trong cơ sở dữ liệu.
Nếu thay ảnh mới, nên xóa ảnh cũ để tránh tệp không còn sử dụng.
use Illuminate\Support\Facades\Storage;
if (
$post->image &&
Storage::disk('public')
->exists($post->image)
) {
Storage::disk('public')
->delete($post->image);
}
Sau đó mới lưu ảnh mới.
Điều này giúp tiết kiệm dung lượng lưu trữ.
Có thể lấy tên gốc:
$request
->file('image')
->getClientOriginalName();
Hoặc:
$request
->file('image')
->extension();
Tuy nhiên, trong hầu hết trường hợp, nên để Laravel tự tạo tên tệp để tránh trùng lặp.
Ví dụ:
<input
type="file"
name="images[]"
multiple>
Controller:
foreach (
$request->file('images')
as $image
) {
$image->store(
'posts',
'public'
);
}
Kỹ thuật này thường dùng cho thư viện ảnh hoặc nhiều tệp đính kèm.
Người dùng
│
▼
Chọn ảnh
│
▼
Validation
│
▼
Lưu vào Storage
│
▼
Lưu đường dẫn vào Database
│
▼
Hiển thị bằng asset('storage/...')
Đây là quy trình chuẩn trong hầu hết các ứng dụng Laravel.
Trong bài này chúng ta đã học:
Upload File.
multipart/form-data.
hasFile().
store().
storage:link.
Hiển thị ảnh.
Cập nhật ảnh.
Xóa ảnh cũ.
Upload nhiều ảnh.
Upload File là chức năng không thể thiếu trong Blog, CMS, hệ thống quản lý tài liệu và nhiều ứng dụng web hiện đại.
Thêm cột image vào bảng posts.
Tạo Form cho phép tải ảnh.
Validation chỉ cho phép ảnh JPG, PNG và WebP.
Lưu ảnh vào thư mục storage/app/public/posts.
Hiển thị ảnh trong danh sách bài viết.
Khi cập nhật bài viết, thay thế ảnh cũ bằng ảnh mới.
Xóa ảnh cũ khỏi Storage khi không còn sử dụng.
Đến đây, bạn đã hoàn thành các kiến thức nền tảng về xử lý biểu mẫu trong Laravel:
Request
Validation
Upload File
Đây là ba thành phần cốt lõi được sử dụng gần như trong mọi chức năng nhập liệu của một ứng dụng Laravel.
Ở Bài 21, chúng ta sẽ bắt đầu PHẦN 6 — AUTHENTICATION với Laravel Breeze, giải pháp xác thực chính thức của Laravel giúp nhanh chóng xây dựng hệ thống đăng nhập, đăng ký và quản lý người dùng theo chuẩn hiện đại.
x1
Cập nhật: 2026-07-29T20:29:35.681+07:00