Thiết kế tính năng Wishlist chuẩn Enterprise
Thông thường, mọi người sẽ tạo một bảng wishlists gồm 2 cột: user_id và product_id. Xong!
Nhưng chuyện gì xảy ra nếu 6 tháng sau, dự án mở rộng, sếp yêu cầu user có thể thả tim bài viết (Article), thả tim khóa học (Course), hay thả tim gian hàng (Shop)? Lúc đó, bạn lại lật đật đi tạo thêm cột article_id, course_id... rồi lại if/else rối mù?
Hôm nay, mình sẽ hướng dẫn bạn thiết kế tính năng Wishlist chuẩn Enterprise ngay từ đầu bằng Polymorphic Relations (Quan hệ đa hình) và Toggle Action (Hành động gộp). Một khi đã làm xong bộ khung này, bạn có thể cho phép user "thả tim" bất kỳ thực thể nào trong hệ thống mà không cần sửa lại Database.
Chúng ta cùng bắt đầu!
Bước 1: Khởi tạo và Thiết kế Database (Polymorphic)
Tạo dự án mới:
laravel new enterprise-wishlist
cd enterprise-wishlist
1. Tạo Model và Migration cho Wishlist
php artisan make:model Wishlist -m
Bí quyết ở đây là sử dụng hàm morphs của Laravel. Mở file migration vừa tạo ra:
// database/migrations/xxxx_create_wishlists_table.php
public function up(): void
{
Schema::create('wishlists', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
// VŨ KHÍ HẠNG NẶNG: Tạo 2 cột wishlistable_id và wishlistable_type
$table->morphs('wishlistable');
$table->timestamps();
// Chống Duplicate: Một user chỉ được thả tim 1 item cụ thể 1 lần duy nhất
$table->unique(
['user_id', 'wishlistable_id', 'wishlistable_type'],
'user_wishlist_unique'
);
});
}
2. Tạo một Model Product để làm mồi test
php artisan make:model Product -m
Migration của Product (Chỉ cần vài cột cơ bản):
public function up(): void
{
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->decimal('price', 15, 2);
$table->timestamps();
});
}
Chạy lệnh gộp: php artisan migrate
Bước 2: Thiết lập Relationship trong Model
Khai báo cho các Model biết chúng có quan hệ đa hình với nhau.
1. Model Wishlist.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Wishlist extends Model
{
protected $fillable = ['user_id', 'wishlistable_id', 'wishlistable_type'];
/**
* Lấy thực thể đang được thả tim (Có thể là Product, Article, v.v...)
*/
public function wishlistable()
{
return $this->morphTo();
}
}
2. Model User.php
// Thêm hàm này vào class User
public function wishlists()
{
return $this->hasMany(Wishlist::class);
}
3. Model Product.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
protected $fillable = ['name', 'price'];
// Bất cứ Model nào muốn được "Thả tim" đều cần hàm này
public function wishlistedBy()
{
return $this->morphMany(Wishlist::class, 'wishlistable');
}
}
Bước 3: Action Pattern - Xử lý logic "Toggle"
Trong UX hiện đại, người ta không tách ra 2 nút Add và Remove riêng. Họ dùng chung 1 nút (như nút Trái tim). Bấm lần 1 là Add, bấm lần 2 là Remove. Ta gọi logic này là Toggle.
Tạo file Action để xử lý riêng biệt logic này:
mkdir app/Actions
Tạo file app/Actions/ToggleWishlistAction.php:
namespace App\Actions;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
class ToggleWishlistAction
{
/**
* @param User $user Người dùng thực hiện thao tác
* @param Model $model Thực thể cần thao tác (Product, Course...)
* @return array Trạng thái hiện tại
*/
public function execute(User $user, Model $model): array
{
// Tìm xem item này đã có trong wishlist của user chưa
$wishlist = $user->wishlists()
->where('wishlistable_id', $model->getKey())
->where('wishlistable_type', get_class($model))
->first();
if ($wishlist) {
// Đã có -> Hủy (Remove)
$wishlist->delete();
return ['status' => 'removed', 'message' => 'Đã bỏ khỏi danh sách yêu thích.'];
}
// Chưa có -> Thêm mới (Add)
$user->wishlists()->create([
'wishlistable_id' => $model->getKey(),
'wishlistable_type' => get_class($model),
]);
return ['status' => 'added', 'message' => 'Đã thêm vào danh sách yêu thích.'];
}
}
Bước 4: Controller Mỏng Nhẹ
Bây giờ chúng ta tạo Controller để gọi Action trên.
php artisan make:controller Api/WishlistController
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Actions\ToggleWishlistAction;
use Illuminate\Database\Eloquent\Relations\Relation;
class WishlistController extends Controller
{
/**
* API: Thêm/Bỏ yêu thích
*/
public function toggle(Request $request, ToggleWishlistAction $action)
{
// 1. Validate đầu vào
$request->validate([
'item_type' => 'required|string', // VD: 'product', 'article'
'item_id' => 'required|integer',
]);
// 2. Map string thành Model Class
// Để bảo mật, ta tạo một mảng ánh xạ để user không thể ném bừa class hệ thống vào
$allowedTypes = [
'product' => \App\Models\Product::class,
// 'article' => \App\Models\Article::class,
];
if (!array_key_exists($request->item_type, $allowedTypes)) {
return response()->json(['message' => 'Loại thực thể không hợp lệ.'], 400);
}
$modelClass = $allowedTypes[$request->item_type];
// 3. Tìm thực thể trong DB
$model = $modelClass::findOrFail($request->item_id);
// 4. Xử lý logic qua Action
$result = $action->execute($request->user(), $model);
return response()->json([
'success' => true,
'action' => $result['status'], // 'added' hoặc 'removed'
'message' => $result['message']
]);
}
/**
* API: Lấy danh sách đang yêu thích
*/
public function index(Request $request)
{
// Lấy danh sách wishlist, load sẵn thực thể bên trong để chống N+1 Query
$wishlists = $request->user()->wishlists()->with('wishlistable')->latest()->get();
// Map dữ liệu để trả về JSON đẹp mắt
$formattedData = $wishlists->map(function ($item) {
return [
'wishlist_id' => $item->id,
'added_at' => $item->created_at->format('Y-m-d H:i:s'),
'item_type' => class_basename($item->wishlistable_type),
'item_detail' => $item->wishlistable // Sẽ hiển thị thông tin Product
];
});
return response()->json([
'success' => true,
'data' => $formattedData
]);
}
}
Đăng ký Route (yêu cầu login):
// routes/api.php
use App\Http\Controllers\Api\WishlistController;
Route::middleware('auth:sanctum')->group(function () {
Route::post('/wishlist/toggle', [WishlistController::class, 'toggle']);
Route::get('/wishlist', [WishlistController::class, 'index']);
});
Bước 5: Chuẩn bị Dữ liệu mẫu (Tinker) & Test Postman
Để test, ta cần 1 User (có token) và vài Product trong DB. Mở terminal:
php artisan tinker
Chạy các lệnh sau:
// Tạo User
$user = User::factory()->create();
echo $user->createToken('Test')->plainTextToken;
// => LƯU LẠI CHUỖI TOKEN VỪA IN RA (VD: 1|ABC...)
// Tạo vài Product
App\Models\Product::create(['name' => 'MacBook Pro M3', 'price' => 50000000]);
App\Models\Product::create(['name' => 'Bàn phím cơ Keychron', 'price' => 2500000]);
Bật server: php artisan serve
Test 1: Thả tim (Add to Wishlist)
- Method:
POST - URL:
[http://127.0.0.1:8000/api/wishlist/toggle](http://127.0.0.1:8000/api/wishlist/toggle) - Headers:
Accept:application/jsonAuthorization:Bearer {token_cua_ban}- Body (JSON):
{
"item_type": "product",
"item_id": 1
}
Kết quả:
{
"success": true,
"action": "added",
"message": "Đã thêm vào danh sách yêu thích."
}
Test 2: Bỏ thả tim (Remove from Wishlist) Nhấn nút SEND trên Postman đúng Request vừa nãy một lần nữa (Cùng data). Kết quả:
{
"success": true,
"action": "removed",
"message": "Đã bỏ khỏi danh sách yêu thích."
}
(Chức năng Toggle hoạt động cực mượt! FE chỉ việc đổi icon trái tim thành màu xám). Hãy bấm SEND lần nữa để Add lại vào wishlist nhé.
Test 3: Xem danh sách Wishlist
- Method:
GET - URL:
[http://127.0.0.1:8000/api/wishlist](http://127.0.0.1:8000/api/wishlist) - Headers:
Authorization:Bearer {token_cua_ban}
Kết quả:
{
"success": true,
"data": [
{
"wishlist_id": 1,
"added_at": "2026-05-05 10:00:00",
"item_type": "Product",
"item_detail": {
"id": 1,
"name": "MacBook Pro M3",
"price": "50000000.00",
"created_at": "2026-05-05T09:00:00.000000Z",
"updated_at": "2026-05-05T09:00:00.000000Z"
}
}
]
}
Tóm lại
Thiết kế Wishlist theo kiến trúc Polymorphic kết hợp Toggle Action mang lại 3 ưu điểm sống còn cho một dự án Enterprise:
- Dễ scale: Mai mốt ra thêm tính năng
Article, bạn chỉ cần khai báo thêm vào mảng$allowedTypes, không cần tạo thêm bất kỳ bảng hay cột DB nào mới. - Idempotent API: Frontend nhàn hạ, gọi chung 1 cục API để xử lý nút Like/Unlike mà không sợ bị lệch pha dữ liệu.
- Tối ưu DB: Mọi truy vấn đều qua Index, chống duplicate tuyệt đối ở tầng cơ sở dữ liệu.
Chúc bạn đem source code này áp dụng thẳng vào dự án thành công nhé!
All rights reserved