Bạn hoàn toàn có thể dùng AdminLTE trong Laravel 13 với cơ chế
@vite
, vì hiện nay AdminLTE đã có gói chính thức cho Laravel hỗ trợ Vite-first pipeline.
Điều này giúp bạn vừa tận dụng thư viện cũ, vừa đảm bảo bảo mật, minify và tree-shaking.
Chạy lệnh Composer:
composer require colorlibhq/adminlte-laravel
Sau đó chạy artisan installer:
php artisan adminlte:install
👉 Lệnh này sẽ:
Xuất file cấu hình config/adminlte.php.
Tạo stub cho Vite tại resources/js/adminlte.js và resources/css/adminlte.css.
Cài đặt các dependency frontend (AdminLTE, Bootstrap 5.3, Popper, Icons, ApexCharts, v.v.).
Trong vite.config.js, thêm entry cho AdminLTE:
import { defineConfig } from 'vite'
import laravel from 'laravel-vite-plugin'
export default defineConfig({
plugins: [
laravel({
input: [
'resources/css/adminlte.css',
'resources/js/adminlte.js',
],
refresh: true,
}),
],
})
👉 Nhờ vậy, khi chạy npm run build, toàn bộ asset sẽ được minify, hash và tree-shake để tăng bảo mật.
Ví dụ resources/views/dashboard.blade.php:
@extends('adminlte::page')
@section('title', 'Dashboard')
@section('content_header')
<h3 class="mb-0">Dashboard</h3>
@stop
@section('content')
<x-adminlte-info-box title="Orders" text="150" icon="bi bi-bag" theme="primary" />
@stop
👉 Các component <x-adminlte-*> được cung cấp sẵn, không cần copy HTML thủ công.
npm run build) để đảm bảo asset được minify và hash.
config/adminlte.php để giảm dung lượng bundle.
php artisan adminlte:status
để biết tài nguyên nào đã được cài đặt.
👉 Nếu bạn muốn giữ nguyên AdminLTE 3 nhưng vẫn dùng @vite,
bạn phải tự import thủ công từ npm
(admin-lte@^3) và cấu hình entry trong vite.config.js.
Tuy nhiên, giải pháp tốt nhất là chuyển sang AdminLTE 4
vì đã có package chính thức cho Laravel 13.
@vite(['resources/css/app.css', 'resources/js/app.js'])
app.scss thay vì app.css để override biến của Bootstrap.
npm run build), Vite sẽ tự động minify, hash và tree-shake → bảo mật và tối ưu dung lượng.
npm install @fortawesome/fontawesome-free
Trong resources/js/app.js:
import '@fortawesome/fontawesome-free/js/all.js';
Trong resources/css/app.css (hoặc app.scss):
@import "@fortawesome/fontawesome-free/css/all.min.css";
Trong vite.config.js, đảm bảo đã khai báo:
import { defineConfig } from 'vite'
import laravel from 'laravel-vite-plugin'
export default defineConfig({
plugins: [
laravel({
input: [
'resources/css/app.css',
'resources/js/app.js',
],
refresh: true,
}),
],
})
@vite(['resources/css/app.css', 'resources/js/app.js'])
Sau đó bạn có thể dùng icon:
<i class="fas fa-user"></i>
<i class="fab fa-laravel"></i>
all.js.
npm run build), Vite sẽ tự động minify và hash để tăng bảo mật.
Cập nhật: 2026-09-17T16:00:38.525+07:00
REST API là cầu nối để Laravel giao tiếp với Website, JavaScript, Mobile App hoặc các hệ thống khác thông qua HTTP và dữ liệu JSON.
Ở phần trước, chúng ta đã hoàn thành Logging & Debug.
Từ bài này, chúng ta bước sang:
Gồm:
Bài 41 — REST API
Bài 42 — API Resource
Bài 43 — Sanctum
Bài 44 — API Authentication
Mục tiêu của phần này là biến Laravel từ một Website thông thường thành một Backend API có thể cung cấp dữ liệu cho nhiều loại ứng dụng khác nhau.
Khi xây dựng một Website Laravel truyền thống:
Browser
↓
Laravel
↓
Blade
↓
HTML
Laravel xử lý request rồi trả về HTML để trình duyệt hiển thị.
Ví dụ:
GET /blog
Laravel trả về:
<html>
...
</html>
Nhưng với API, cách hoạt động sẽ khác:
Website
Mobile App
JavaScript
React
Vue
Next.js
↓
REST API
↓
Laravel
↓
Database
Laravel không nhất thiết trả về HTML.
Thay vào đó, Laravel trả về:
{
"id": 1,
"title": "Laravel 13 là gì?"
}
Đây chính là nền tảng của API.
API là viết tắt của:
Application Programming Interface
Có thể hiểu đơn giản:
API là một "cổng giao tiếp" cho phép các chương trình trao đổi dữ liệu với nhau.
Ví dụ Blog CMS của chúng ta có bài viết:
ID: 1
Title: Laravel 13 là gì?
Category: Laravel
Website có thể lấy dữ liệu bằng:
GET /api/posts
Laravel trả về:
[
{
"id": 1,
"title": "Laravel 13 là gì?"
},
{
"id": 2,
"title": "Routing trong Laravel 13"
}
]
Một ứng dụng mobile có thể gọi chính API đó.
Một ứng dụng React cũng có thể gọi.
Một ứng dụng Vue cũng có thể gọi.
Như vậy:
┌── Website
│
├── Mobile App
│
Laravel API ─────┼── React
│
├── Vue
│
└── Third-party App
Backend chỉ cần cung cấp API.
REST là viết tắt của:
Representational State Transfer
REST không phải là một thư viện.
REST là một phong cách thiết kế API dựa trên HTTP.
Ví dụ chúng ta có đối tượng:
Post
API có thể thiết kế:
GET /api/posts
GET /api/posts/1
POST /api/posts
PUT /api/posts/1
DELETE /api/posts/1
Nhìn vào URL và HTTP method, chúng ta có thể hiểu API đang làm gì.
REST API rất phù hợp với CRUD.
| CRUD | HTTP | Endpoint |
|---|---|---|
| Create | POST | /api/posts |
| Read | GET | /api/posts |
| Read one | GET | /api/posts/1 |
| Update | PUT/PATCH | /api/posts/1 |
| Delete | DELETE | /api/posts/1 |
Có thể hình dung:
POSTS API
GET /api/posts
↓
Danh sách bài viết
GET /api/posts/1
↓
Chi tiết bài viết
POST /api/posts
↓
Tạo bài viết
PUT /api/posts/1
↓
Cập nhật bài viết
DELETE /api/posts/1
↓
Xóa bài viết
Đây là cấu trúc rất phổ biến khi thiết kế REST API.
REST API sử dụng các HTTP method chính.
Dùng để lấy dữ liệu.
GET /api/posts
Ví dụ:
Lấy danh sách bài viết
Dùng để tạo dữ liệu mới.
POST /api/posts
Ví dụ gửi:
{
"title": "Laravel 13 REST API",
"category_id": 1
}
Thường dùng để cập nhật toàn bộ resource.
PUT /api/posts/1
Thường dùng để cập nhật một phần resource.
PATCH /api/posts/1
Ví dụ chỉ thay đổi:
{
"title": "Laravel 13 API"
}
Dùng để xóa resource.
DELETE /api/posts/1
Một trong những điểm quan trọng nhất khi xây dựng API là dữ liệu thường được trao đổi dưới dạng JSON.
Ví dụ:
{
"id": 1,
"title": "Laravel 13",
"status": "published"
}
Laravel có thể trả về JSON rất đơn giản.
Ví dụ:
return response()->json([
'message' => 'Hello API'
]);
Kết quả:
{
"message": "Hello API"
}
Laravel cũng có thể tự chuyển array thành JSON response trong nhiều trường hợp.
Một điểm rất quan trọng đối với người học Laravel hiện nay:
Laravel không nhất thiết tạo sẵn routes/api.php trong một project mới.
API routing là phần có thể cài đặt thêm.
Trong Laravel, chúng ta có thể sử dụng:
php artisan install:api
Lệnh này dùng để cài đặt API routing và chuẩn bị cấu trúc cần thiết cho API.&
Laravel cũng sử dụng lệnh này trong quá trình thiết lập Sanctum ở các phiên bản hiện đại.
Sau khi chạy:
php artisan install:api
project sẽ có thêm:
routes/
├── web.php
├── console.php
└── api.php
web.php và api.phpCó thể hiểu đơn giản:
routes/web.phpDành cho Website:
Browser
↓
web.php
↓
Controller
↓
Blade
↓
HTML
routes/api.phpDành cho API:
Client
↓
api.php
↓
Controller
↓
JSON
API routes hướng tới các request stateless, khác với nhóm route web vốn có session, cookie và CSRF protection.
Sau khi chạy:
php artisan install:api
mở:
routes/api.php
Thêm:
<?php
use Illuminate\Support\Facades\Route;
Route::get('/hello', function () {
return response()->json([
'message' => 'Hello Laravel 13 API'
]);
});
Chạy Laravel:
php artisan serve
Sau đó truy cập:
http://127.0.0.1:8000/api/hello
Kết quả:
{
"message": "Hello Laravel 13 API"
}
Chúng ta vừa tạo API đầu tiên.
📌 Ghi chú:
🔄 Vào tab Network trong DevTools của trình duyệt.
🔃 Refresh lại trang để tải lại request.
📂 Bấm vào dòng request trả về (thường nằm trên cùng).
👀 Chọn thẻ Response để xem rõ nội dung JSON.
Như vậy bạn sẽ thấy body JSON đầy đủ, không chỉ status 200 ở thẻ Headers.
/api?Khi route được khai báo trong API routing:
Route::get('/hello', ...);
URL API thường sẽ là:
/api/hello
Thay vì:
/hello
Do đó:
routes/api.php
có thể chứa:
Route::get('/posts', ...);
và client gọi:
GET /api/posts
Bây giờ chúng ta áp dụng vào Blog CMS.
Model:
app/Models/Post.php
Trong:
routes/api.php
có thể viết:
use App\Models\Post;
use Illuminate\Support\Facades\Route;
Route::get('/posts', function () {
return Post::all();
});
Truy cập:
GET /api/posts
Laravel có thể chuyển Eloquent model hoặc collection thành JSON response.
Ví dụ kết quả:
[
{
"id": 1,
"user_id": 1,
"category_id": 2,
"title": "Laravel 13 là gì?",
"slug": "laravel-13-la-gi",
"status": "published"
},
{
"id": 2,
"user_id": 1,
"category_id": 3,
"title": "Laravel Routing",
"slug": "laravel-routing",
"status": "published"
}
]
Đoạn này:
Route::get('/posts', function () {
return Post::all();
});
chạy được.
Nhưng khi project lớn lên, chúng ta không nên đưa toàn bộ logic vào route.
Thay vào đó:
Route
↓
Controller
↓
Model
↓
Database
Đây cũng chính là cách chúng ta đã học ở những bài Controller và CRUD trước đó.
Chạy:
php artisan make:controller Api/PostController
Laravel tạo:
app/
└── Http/
└── Controllers/
└── Api/
└── PostController.php
Mở file:
app/Http/Controllers/Api/PostController.php
Viết:
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Post;
class PostController extends Controller
{
public function index()
{
return Post::all();
}
}
Mở:
routes/api.php
Viết:
<?php
use App\Http\Controllers\Api\PostController;
use Illuminate\Support\Facades\Route;
Route::get('/posts', [PostController::class, 'index']);
Bây giờ:
GET /api/posts
sẽ chạy:
PostController@index
Luồng hoạt động:
GET /api/posts
↓
routes/api.php
↓
PostController@index
↓
Post::all()
↓
Database
↓
JSON
Thêm vào Controller:
public function show(Post $post)
{
return $post;
}
Route:
Route::get('/posts/{post}', [PostController::class, 'show']);
Bây giờ:
GET /api/posts/1
Laravel sẽ tìm:
Post ID = 1
và trả về JSON.
Ví dụ:
{
"id": 1,
"title": "Laravel 13 là gì?",
"slug": "laravel-13-la-gi",
"status": "published"
}
Đây chính là Route Model Binding mà chúng ta đã học ở phần Routing.
Trong Blog CMS, chúng ta không muốn API công khai trả về tất cả bài viết.
Ví dụ:
draft
hidden
scheduled
không nên xuất hiện trong API public.
Model Post của chúng ta đã có scope:
Post::published()
Do đó Controller có thể viết:
public function index()
{
return Post::published()
->with(['user', 'category'])
->latest('published_at')
->paginate(10);
}
Khi đó:
GET /api/posts
chỉ lấy những bài đã publish.
Đây chính là cách kết hợp:
Eloquent
+
Scope
+
Relationship
+
Pagination
+
REST API
Thay vì trả thẳng:
return Post::all();
chúng ta có thể tạo response:
return response()->json([
'success' => true,
'data' => Post::all()
]);
Kết quả:
{
"success": true,
"data": [
{
"id": 1,
"title": "Laravel 13 là gì?"
},
{
"id": 2,
"title": "Laravel Routing"
}
]
}
Cách này giúp client dễ xử lý response.
API không chỉ trả dữ liệu.
API còn cần trả về HTTP status code phù hợp.
Một số status code quan trọng:
| Code | Ý nghĩa |
|---|---|
| 200 | OK |
| 201 | Created |
| 204 | No Content |
| 400 | Bad Request |
| 401 | Unauthorized |
| 403 | Forbidden |
| 404 | Not Found |
| 422 | Validation Error |
| 500 | Server Error |
Ví dụ lấy dữ liệu thành công:
return response()->json([
'data' => $posts
], 200);
Ví dụ:
public function store(Request $request)
{
$post = Post::create([
'user_id' => $request->user()->id,
'title' => $request->title,
'slug' => $request->slug,
'content' => $request->content,
]);
return response()->json([
'message' => 'Post created successfully',
'data' => $post
], 201);
}
Nhớ import:
use Illuminate\Http\Request;
Client gửi:
POST /api/posts
Body:
{
"title": "Laravel 13 REST API",
"slug": "laravel-13-rest-api",
"content": "..."
}
Nếu tạo thành công:
HTTP 201 Created
Ghi chú:
lấy hàm store trong Bài 27 — CRUD Posts rồi sửa khúc return là xong.
API vẫn cần Validation giống Website.
Ví dụ:
$request->validate([
'title' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255'],
'content' => ['required', 'string'],
]);
Nếu dữ liệu không hợp lệ, Laravel có thể trả về lỗi validation dưới dạng JSON đối với request API.
Ví dụ client có thể nhận:
{
"message": "The title field is required.",
"errors": {
"title": [
"The title field is required."
]
}
}
API client có thể dùng thông tin này để hiển thị lỗi cho người dùng.
Ví dụ:
public function update(Request $request, Post $post)
{
$request->validate([
'title' => ['required', 'string', 'max:255'],
'content' => ['required', 'string'],
]);
$post->update([
'title' => $request->title,
'content' => $request->content,
]);
return response()->json([
'message' => 'Post updated successfully',
'data' => $post
]);
}
Route:
Route::put('/posts/{post}', [PostController::class, 'update']);
Client:
PUT /api/posts/1
Controller:
public function destroy(Post $post)
{
$post->delete();
return response()->json([
'message' => 'Post deleted successfully'
]);
}
Route:
Route::delete('/posts/{post}', [PostController::class, 'destroy']);
Client:
DELETE /api/posts/1
Có thể trả về:
{
"message": "Post deleted successfully"
}
Thay vì khai báo từng route:
Route::get('/posts', ...);
Route::get('/posts/{post}', ...);
Route::post('/posts', ...);
Route::put('/posts/{post}', ...);
Route::delete('/posts/{post}', ...);
Laravel hỗ trợ resource routing.
Ví dụ:
Route::apiResource('posts', PostController::class);
Lúc này Laravel tạo nhóm route CRUD cho API.
Có thể kiểm tra bằng:
php artisan route:list
Hoặc:
php artisan route:list --path=api
Bạn sẽ thấy các route tương ứng với:
GET /api/posts
POST /api/posts
GET /api/posts/{post}
PUT/PATCH /api/posts/{post}
DELETE /api/posts/{post}
Đây là cách rất phù hợp cho Blog CMS.
Sau khi học xong phần này, chúng ta có thể hình dung Controller:
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Post;
use Illuminate\Http\Request;
class PostController extends Controller
{
public function index()
{
$posts = Post::published()
->with(['user', 'category'])
->latest('published_at')
->paginate(10);
return response()->json([
'success' => true,
'data' => $posts
]);
}
public function show(Post $post)
{
return response()->json([
'success' => true,
'data' => $post
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255'],
'content' => ['required', 'string'],
]);
$post = Post::create([
...$validated,
'user_id' => $request->user()->id,
]);
return response()->json([
'success' => true,
'message' => 'Post created successfully',
'data' => $post
], 201);
}
public function update(Request $request, Post $post)
{
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255'],
'content' => ['required', 'string'],
]);
$post->update($validated);
return response()->json([
'success' => true,
'message' => 'Post updated successfully',
'data' => $post
]);
}
public function destroy(Post $post)
{
$post->delete();
return response()->json([
'success' => true,
'message' => 'Post deleted successfully'
]);
}
}
Đây là API Controller cơ bản.
Tuy nhiên, trong dự án thực tế chúng ta sẽ chưa dừng ở đây.
routes/api.php:
<?php
use App\Http\Controllers\Api\PostController;
use Illuminate\Support\Facades\Route;
Route::apiResource('posts', PostController::class);
Kết quả:
GET /api/posts
POST /api/posts
GET /api/posts/{post}
PUT /api/posts/{post}
PATCH /api/posts/{post}
DELETE /api/posts/{post}
Đây chính là REST API cho resource:
posts
Các request GET đơn giản có thể test trực tiếp bằng trình duyệt.
Ví dụ:
http://127.0.0.1:8000/api/posts
Hoặc:
http://127.0.0.1:8000/api/posts/1
Browser sẽ hiển thị JSON.
Ví dụ:
{
"success": true,
"data": {
"id": 1,
"title": "Laravel 13"
}
}
Khi API có:
GET
POST
PUT
PATCH
DELETE
thì Postman sẽ thuận tiện hơn trình duyệt.
Ví dụ:
GET
http://127.0.0.1:8000/api/posts
POST:
POST
http://127.0.0.1:8000/api/posts
Body:
{
"title": "Bài viết mới",
"slug": "bai-viet-moi",
"content": "Nội dung bài viết"
}
PUT:
PUT
http://127.0.0.1:8000/api/posts/1
DELETE:
DELETE
http://127.0.0.1:8000/api/posts/1
Như vậy Postman có thể đóng vai trò như một client của Laravel API.
Đây là điểm người mới học Laravel rất dễ nhầm.
Website:
GET /blog
↓
Controller
↓
Blade
↓
HTML
API:
GET /api/posts
↓
Controller
↓
Eloquent
↓
JSON
Hai thứ đều sử dụng Laravel.
Nhưng mục đích khác nhau.
Ví dụ chúng ta có:
Laravel 13 API
│
┌────────────┼────────────┐
↓ ↓ ↓
Website Android iOS
│ │ │
└────────────┼────────────┘
↓
Database
API trở thành tầng trung gian giữa frontend và database.
Ví dụ:
GET /api/posts
Website lấy dữ liệu.
Mobile App cũng gọi:
GET /api/posts
Một ứng dụng React cũng có thể gọi:
GET /api/posts
Không cần xây dựng ba hệ thống backend riêng biệt.
Project hiện tại có:
users
categories
posts
Sau khi hoàn thành phần API, chúng ta có thể xây dựng:
/api/posts
/api/categories
/api/users
Ví dụ:
GET /api/posts
GET /api/posts/1
GET /api/categories
GET /api/categories/1
Sau này có thể phát triển tiếp:
POST /api/posts
PUT /api/posts/1
DELETE /api/posts/1
Nhưng vấn đề tiếp theo xuất hiện:
Dữ liệu trả về có nên được đưa thẳng từ Model ra JSON hay không?
Ví dụ Model Post có:
user_id
category_id
view_count
created_at
updated_at
Trong khi frontend có thể chỉ cần:
id
title
slug
excerpt
image
published_at
category
author
Đây chính là lý do chúng ta cần học API Resource.
Hiện tại:
return $post;
Laravel có thể chuyển Model thành JSON.
Nhưng chúng ta không kiểm soát tốt cấu trúc dữ liệu.
API Resource cho phép định nghĩa:
Model
↓
Resource
↓
JSON
Ví dụ:
Post
↓
PostResource
↓
{
id,
title,
slug,
excerpt,
image,
category,
author
}
Đây sẽ là nội dung của:
Một API public không có nghĩa là:
Ai cũng được phép làm mọi thứ.
Ví dụ:
GET /api/posts
có thể public.
Nhưng:
POST /api/posts
PUT /api/posts/1
DELETE /api/posts/1
thì cần kiểm tra người dùng.
Có thể cần:
Authentication
Authorization
Token
Permission
Policy
Đây chính là phần chúng ta sẽ học tiếp trong:
Bài 43 — Sanctum
Bài 44 — API Authentication
Vì vậy trong bài này chúng ta mới chỉ xây dựng REST API cơ bản.
Sau khi hoàn thành Phần 9, kiến trúc sẽ có dạng:
Laravel 13
│
┌──────────┴──────────┐
│ │
Website API
│ │
web.php api.php
│ │
Controller Controller
│ │
Blade Resource
│ │
HTML JSON
│ │
└──────────┬──────────┘
↓
Eloquent
↓
MySQL
Đây là một bước rất quan trọng trong quá trình chuyển từ:
Laravel Website
sang:
Laravel Backend API
Cài API routing:
php artisan install:api
Kiểm tra:
routes/api.php
Tạo:
/api/hello
Trả về:
{
"message": "Hello Laravel 13 API"
}
Tạo:
GET /api/posts
Trả về danh sách Post.
Tạo:
GET /api/posts/{post}
Trả về một Post.
Tạo REST CRUD:
GET /api/posts
GET /api/posts/{post}
POST /api/posts
PUT /api/posts/{post}
DELETE /api/posts/{post}
Có thể sử dụng:
Route::apiResource('posts', PostController::class);
Dùng Postman kiểm tra:
GET
POST
PUT
DELETE
và quan sát:
HTTP Status Code
JSON Response
Validation Error
Trong bài này, chúng ta đã làm quen với:
REST API
API là gì?
REST là gì?
HTTP Methods
GET
POST
PUT
PATCH
DELETE
JSON
API routes
php artisan install:api
routes/api.php
API Controller
Route Model Binding
response()->json()
HTTP Status Code
Validation
Route::apiResource()
Test API bằng Browser
Test API bằng Postman
REST API trong Blog CMS
Quan trọng nhất cần nhớ:
Website
↓
HTML
API
↓
JSON
Và:
GET → Read
POST → Create
PUT → Update
PATCH → Update một phần
DELETE → Delete
Một REST API cơ bản của Blog CMS:
/api/posts
/api/posts/{post}
có thể trở thành backend dùng chung cho:
Website
Mobile App
React
Vue
Next.js
Third-party Application
Chúng ta đã có REST API.
Nhưng response hiện tại vẫn còn khá "thô":
return $post;
hoặc:
return Post::all();
Ở bài tiếp theo, chúng ta sẽ học cách kiểm soát chính xác dữ liệu JSON trả về bằng:
Luồng xử lý sẽ trở thành:
Database
↓
Eloquent Model
↓
API Resource
↓
JSON
↓
Frontend / Mobile App
Đây là bước rất quan trọng để biến API Laravel thành một API có cấu trúc rõ ràng và phù hợp với dự án thực tế.
Cập nhật: 2026-09-17T11:51:38.433+07:00
Khi ứng dụng còn nhỏ, chúng ta có thể nhìn vào màn hình và đoán lỗi.
Nhưng khi Blog CMS bắt đầu có nhiều Controller, Model, Middleware, Event, Queue, Mail, Cache và Storage thì việc theo dõi hoạt động của ứng dụng trở nên rất quan trọng.
Laravel cung cấp hệ thống Logging và nhiều công cụ Debug giúp chúng ta biết:
Request nào đang xảy ra.
Query nào được thực thi.
Exception nào xuất hiện.
User nào đang thực hiện hành động.
Queue nào đang chạy.
Ứng dụng đang gặp vấn đề ở đâu.
Trong bài này chúng ta tìm hiểu ba công cụ chính:
Log
Debugbar
Telescope
Logging là quá trình ghi lại thông tin hoạt động của ứng dụng.
Ví dụ:
User đăng nhập
Post được tạo
Post được cập nhật
File được upload
Email được gửi
Exception xảy ra
Thay vì chỉ nhìn thấy:
500 Server Error
Log có thể cho chúng ta biết:
16:20:31
PostController@update
Post ID: 25
Image upload failed
Nhờ đó việc tìm lỗi trở nên dễ dàng hơn.
Laravel sử dụng hệ thống Logging dựa trên:
Monolog
Ứng dụng có thể ghi Log thông qua:
use Illuminate\Support\Facades\Log;
Ví dụ:
Log::info('Laravel 13 đang chạy');
Laravel cung cấp nhiều Log level.
Một số level thường gặp:
emergency
alert
critical
error
warning
notice
info
debug
Có thể hình dung:
Emergency
↑
Alert
↑
Critical
↑
Error
↑
Warning
↑
Notice
↑
Info
↑
Debug
Trong ứng dụng thông thường, chúng ta thường sử dụng:
Log::debug();
Log::info();
Log::warning();
Log::error();
Dùng để ghi thông tin hoạt động bình thường:
Log::info('User đã đăng nhập');
Ví dụ trong Controller:
Log::info('Post được tạo thành công');
Dùng khi có vấn đề cần chú ý nhưng chưa phải lỗi nghiêm trọng:
Log::warning(
'Post chưa có hình ảnh'
);
Ví dụ:
if (!$post->image) {
Log::warning(
'Post không có image',
[
'post_id' => $post->id,
]
);
}
Dùng khi xảy ra lỗi:
Log::error(
'Không thể upload image'
);
Có thể truyền thêm context:
Log::error(
'Không thể upload image',
[
'post_id' => $post->id,
]
);
Kết quả Log sẽ có thêm thông tin giúp chúng ta xác định lỗi.
debug() thường được sử dụng trong quá trình phát triển:
Log::debug(
'Post update',
[
'post_id' => $post->id,
]
);
Ví dụ:
Post update
post_id = 15
Điều này rất hữu ích khi muốn theo dõi flow của code.
Một trong những tính năng quan trọng của Log là Context.
Thay vì:
Log::info('Post updated');
ta có thể:
Log::info(
'Post updated',
[
'post_id' => $post->id,
'user_id' => auth()->id(),
]
);
Khi đó Log không chỉ nói:
Post updated
mà còn biết:
post_id
user_id
Đây là thói quen rất tốt khi xây dựng ứng dụng thực tế.
Laravel mặc định có thể ghi Log vào:
storage/logs/
Ví dụ:
storage/
└── logs/
└── laravel.log
Khi ứng dụng xảy ra lỗi hoặc chúng ta gọi:
Log::error(...)
thông tin có thể được ghi vào file Log.
Ví dụ Log:
Log::info(
'Post created',
[
'post_id' => 25,
'user_id' => 1,
]
);
Sau đó mở:
storage/logs/laravel.log
ta có thể tìm thấy thông tin tương ứng.
Ví dụ khi tạo Post:
$post = Post::create($validated);
Log::info(
'Post created',
[
'post_id' => $post->id,
'user_id' => auth()->id(),
]
);
Khi cập nhật:
Log::info(
'Post updated',
[
'post_id' => $post->id,
'user_id' => auth()->id(),
]
);
Khi xóa:
Log::info(
'Post deleted',
[
'post_id' => $post->id,
'user_id' => auth()->id(),
]
);
Như vậy chúng ta có thể theo dõi các hoạt động quan trọng của Blog CMS.
Có thể kết hợp Exception với Log:
try {
$post->update($validated);
} catch (\Throwable $e) {
Log::error(
'Post update failed',
[
'post_id' => $post->id,
'error' => $e->getMessage(),
]
);
throw $e;
}
Điều này giúp ghi lại lỗi trước khi Exception tiếp tục được xử lý.
Có thể ghi trực tiếp Exception:
try {
// Code
} catch (\Throwable $e) {
Log::error(
'Unexpected error',
[
'exception' => $e,
]
);
throw $e;
}
Thông tin Exception giúp quá trình Debug dễ dàng hơn rất nhiều.
Một quan niệm sai:
Log = Error
Không đúng.
Log có thể dùng để theo dõi:
Application Flow
User Activity
Business Event
Warning
Error
Performance
Ví dụ:
Log::info('Post published');
không phải lỗi.
Nó chỉ là thông tin quan trọng về hoạt động của hệ thống.
Laravel hỗ trợ nhiều Log Channel.
Có thể hình dung:
Laravel
│
└── Logging
│
├── single
├── daily
├── stack
└── ...
Cấu hình nằm tại:
config/logging.php
Một cấu hình thường dùng là Daily Log.
Thay vì:
laravel.log
mọi thứ dồn vào một file rất lớn, có thể chia theo ngày:
laravel-2026-09-13.log
laravel-2026-09-14.log
laravel-2026-09-15.log
Điều này thuận tiện khi hệ thống hoạt động lâu dài.
Có thể chỉ định channel:
Log::channel('daily')->info(
'Post created'
);
Nếu project có channel riêng:
Log::channel('payments')->error(
'Payment failed'
);
Nhờ vậy những loại Log khác nhau có thể được tách riêng.
Debug là quá trình tìm nguyên nhân khiến chương trình hoạt động không đúng.
Ví dụ:
Form
↓
Request
↓
Controller
↓
Validation
↓
Model
↓
Database
Nếu dữ liệu không được lưu:
Lỗi nằm ở đâu?
Có thể là:
Form
Request
Validation
Controller
Model
Database
Debug giúp chúng ta tìm chính xác vị trí đó.
Laravel có biến:
APP_DEBUG=true
Khi phát triển Local:
APP_DEBUG=true
có thể giúp hiển thị thông tin Debug chi tiết khi xảy ra Exception.
Ví dụ:
Exception
File
Line
Stack Trace
Khi Deploy:
APP_ENV=production
APP_DEBUG=false
Không nên để:
APP_DEBUG=true
trên Website thật.
Bởi vì thông tin Debug có thể tiết lộ:
File path
Database information
Environment information
Stack trace
Application internals
Đây là vấn đề bảo mật.
Trong Laravel có:
dd();
Tên của nó có thể hiểu là:
Dump and Die
Ví dụ:
dd($post);
Laravel sẽ:
Hiển thị dữ liệu
↓
Dừng chương trình
dd(
$request->all(),
$post,
$user
);
Rất hữu ích khi Debug Controller.
Ví dụ:
public function update(Request $request, Post $post)
{
dd(
$request->all(),
$post
);
// ...
}
Khác với:
dd()
dump() không dừng chương trình.
dump($post);
Sau đó code vẫn tiếp tục chạy.
Có thể:
dump($request->all());
$post->update($validated);
Trong Debug nhanh:
dump()
↓
xem dữ liệu
↓
tiếp tục chạy
Còn:
dd()
↓
xem dữ liệu
↓
dừng
Một công cụ rất phổ biến trong Laravel development là:
Laravel Debugbar
Package phổ biến:
barryvdh/laravel-debugbar
Debugbar có thể giúp quan sát:
Queries
Time
Memory
Route
Views
Events
Tùy cấu hình và phiên bản package.
Trong môi trường development:
composer require barryvdh/laravel-debugbar --dev
Điểm quan trọng là:
--dev
Debugbar phục vụ quá trình phát triển, không phải chức năng người dùng cuối.
Sau khi cài đặt, khi mở Website ở môi trường Local, Debugbar thường xuất hiện ở phía dưới trang.
Có thể hình dung:
┌─────────────────────────────┐
│ Blog CMS │
│ │
│ Nội dung Website │
│ │
└─────────────────────────────┘
───────────────────────────────
Debugbar
Queries | Route | Views | ...
───────────────────────────────
Một tính năng rất hữu ích là xem Database Query.
Ví dụ Controller:
$posts = Post::with([
'user',
'category'
])->latest()->get();
Debugbar có thể cho chúng ta quan sát những Query được thực thi.
Ví dụ:
select * from posts
select * from users where ...
select * from categories where ...
Điều này đặc biệt hữu ích khi học:
Eloquent
Relationship
Eager Loading
N+1 Problem
Ví dụ không tốt:
$posts = Post::all();
Sau đó trong Blade:
@foreach ($posts as $post)
{{ $post->category->name }}
@endforeach
Có thể dẫn tới nhiều Query không cần thiết.
Thay vào đó:
$posts = Post::with('category')->get();
Debugbar giúp chúng ta nhìn thấy sự khác biệt này.
Debugbar cũng có thể giúp quan sát Route hiện tại:
GET /blog
Controller:
BlogController@index
Middleware:
web
auth
...
Điều này hữu ích khi Route trở nên phức tạp.
Có thể quan sát các View được render.
Ví dụ:
blog.index
components.blog.sidebar
components.post.card
Nếu một trang có quá nhiều View hoặc Component, Debugbar giúp chúng ta hiểu quá trình render.
Debugbar cũng có thể cho chúng ta thông tin về:
Time
Memory
Queries
Ví dụ:
Time: 120 ms
Memory: 18 MB
Queries: 8
Đây không phải công cụ benchmark production, nhưng rất hữu ích trong quá trình phát triển.
Khi deploy lên host (production), bạn nên tắt Debugbar để tránh lộ thông tin debug và giảm tải hệ thống.
Debugbar mặc định chỉ nên chạy ở môi trường local. Bạn có thể ép tắt bằng biến môi trường.
.env trên host, thêm dòng: APP_ENV=productionDebugbar được đăng ký trong config/app.php hoặc thông qua auto-discovery.
config/app.phpBarryvdh\Debugbar\ServiceProvider::classTrong file config/debugbar.php, bạn có thể tắt bằng cách:
'enabled' => env('APP_DEBUG', false)APP_DEBUG=false trong .envNếu không cần Debugbar nữa, bạn có thể gỡ bỏ khỏi project.
composer remove barryvdh/laravel-debugbarTóm lại, cách đơn giản nhất là đặt APP_ENV=production và APP_DEBUG=false trong .env trên host, Debugbar sẽ tự động không hiển thị.&
Nếu muốn triệt để, bạn có thể xóa Service Provider hoặc gỡ package.
Laravel còn có một công cụ Debug mạnh hơn:
Laravel Telescope
Telescope là một debugging assistant dành cho Laravel.
Nó cung cấp giao diện để quan sát nhiều hoạt động của ứng dụng.
Ví dụ:
Requests
Commands
Schedule
Jobs
Batches
Database Queries
Exceptions
Logs
Mail
Notifications
Cache
Tùy phiên bản và cấu hình, Telescope cung cấp nhiều watcher khác nhau.
Có thể cài bằng:
composer require laravel/telescope
Sau đó:
php artisan telescope:install
và:
php artisan migrate
Sau khi cài đặt, Telescope có Dashboard riêng.
Ghi chú: vào link này trên local:&http://127.0.0.1:8000/telescope
Có thể hình dung:
Laravel Application
│
▼
Telescope
│
┌──────┼───────────────┐
▼ ▼ ▼
Request Query Exception
│ │ │
▼ ▼ ▼
Job Mail Log
Dashboard giúp Developer quan sát hệ thống từ một nơi.
Telescope có thể theo dõi Request.
Ví dụ:
GET /blog
Có thể quan sát thông tin liên quan đến Request, Response và thời gian xử lý tùy watcher/cấu hình.
Điều này hữu ích khi Debug:
404
403
500
Telescope có thể ghi nhận Database Query.
Ví dụ:
select * from posts
và:
select * from categories
Điều này rất hữu ích khi phân tích:
Eloquent
Relationship
N+1
Slow Query
Nếu ứng dụng xảy ra Exception:
ModelNotFoundException
QueryException
ValidationException
...
Telescope có thể giúp Developer theo dõi Exception tùy cấu hình watcher.
Thay vì chỉ biết:
500
chúng ta có thể điều tra:
Exception
↓
Request
↓
Query
↓
Application Flow
Trong bài Queue trước đó chúng ta đã tìm hiểu Job.
Ví dụ:
SendWelcomeEmail::dispatch($user);
Telescope có thể giúp theo dõi hoạt động liên quan đến Job.
Có thể hình dung:
Job dispatched
↓
Queue
↓
Worker
↓
Job processed
Nếu Job thất bại, công cụ quan sát như Telescope rất hữu ích trong quá trình Debug.
Ứng dụng Blog CMS có thể gửi:
Email
Notification
Telescope có thể giúp Developer theo dõi hoạt động Mail trong môi trường development tùy watcher/configuration.
Điều này rất hữu ích khi:
Email không gửi
Email gửi sai
Mailable không chạy
Queue Mail bị lỗi
Tương tự Mail, Telescope có thể hỗ trợ quan sát Notifications.
Ví dụ:
PostPublished
↓
Notification
↓
User
Nếu Notification không hoạt động như mong muốn, Telescope giúp chúng ta có thêm dữ liệu để điều tra.
Telescope là công cụ rất mạnh.
Nhưng không nên hiểu:
Telescope
=
luôn bật công khai
Đặc biệt với Production, cần cân nhắc:
Authorization
Storage
Performance
Sensitive Data
Retention
Telescope Dashboard nên được bảo vệ bằng authorization phù hợp.
Laravel cũng cung cấp cơ chế pruning để giới hạn lượng dữ liệu Telescope lưu trữ. (laravel.com)
Khi đưa ứng dụng Laravel lên host (production), bạn nên tắt Telescope để tránh lộ dữ liệu debug và giảm tải hệ thống. Có vài cách phổ biến:
Telescope được đăng ký trong AppServiceProvider hoặc bootstrap/providers.php.
App\Providers\AppServiceProviderTelescopeServiceProviderTelescope mặc định chỉ chạy ở môi trường local. Bạn có thể ép tắt bằng biến môi trường.
Trong file .env trên host:
APP_ENV=productionNếu vẫn còn route /telescope, bạn có thể xóa hẳn.
TelescopeServiceProvidercomposer dump-autoloadNếu không cần Telescope nữa, bạn có thể gỡ bỏ khỏi project.
composer remove laravel/telescopeTóm lại, cách đơn giản nhất là đặt APP_ENV=production trong .env trên host, Telescope sẽ tự động không hiển thị.&
Nếu muốn triệt để, bạn có thể xóa Service Provider hoặc gỡ package.
Có thể hiểu đơn giản:
Debug ngay trên trang đang mở
Phù hợp:
Local Development
Query nhanh
Route
View
Performance
Dashboard quan sát ứng dụng
Phù hợp:
Requests
Queries
Jobs
Mail
Notifications
Exceptions
Logs
Có thể hình dung:
DEBUG
┌──────┴──────┐
│ │
Debugbar Telescope
│ │
Nhanh Chi tiết
Local Dashboard
| Công cụ | Mục đích |
|---|---|
Log | Ghi thông tin ứng dụng |
dd() | Kiểm tra dữ liệu nhanh |
dump() | Kiểm tra dữ liệu nhưng tiếp tục chạy |
| Debugbar | Quan sát request hiện tại |
| Telescope | Quan sát hoạt động của Laravel |
Không có công cụ nào thay thế hoàn toàn công cụ còn lại.
Khi Blog CMS gặp lỗi:
User báo lỗi
↓
Kiểm tra Log
↓
Kiểm tra Exception
↓
Kiểm tra Request
↓
Kiểm tra Query
↓
Kiểm tra Controller
↓
Kiểm tra Model
↓
Kiểm tra Database
Trong Local có thể dùng:
dd()
dump()
Debugbar
Telescope
Trong Production nên ưu tiên:
Log
Monitoring
Exception Tracking
và tránh để thông tin Debug nhạy cảm hiển thị cho người dùng.
Giả sử:
public function update(
Request $request,
Post $post
) {
$validated = $request->validate([
'title' => ['required'],
'content' => ['required'],
]);
$post->update($validated);
return redirect()
->route('posts.index');
}
Nhưng dữ liệu không được cập nhật.
Bước đầu:
dd($request->all());
Kiểm tra:
Request có dữ liệu không?
Sau đó:
dd($validated);
Kiểm tra:
Validation có đúng không?
Sau đó:
dd($post);
Kiểm tra:
Route Model Binding có đúng Post không?
Cuối cùng kiểm tra:
Debugbar
hoặc:
Telescope
để xem Query.
Giả sử Upload Image không hoạt động.
Có thể kiểm tra:
dd(
$request->hasFile('image'),
$request->file('image')
);
Nếu:
false
kiểm tra Form:
enctype="multipart/form-data"
Nếu có file:
UploadedFile
thì kiểm tra:
dd(
$request->file('image')->getMimeType()
);
Sau đó kiểm tra Storage.
Đây là cách Debug theo từng tầng thay vì đoán.
Ví dụ:
$posts = Post::with([
'user',
'category'
])->latest()->get();
Nếu nghi ngờ Query quá nhiều:
Debugbar
hoặc:
Telescope
có thể giúp quan sát Query.
Ta có thể phát hiện:
N+1
Query dư thừa
Query quá nhiều
Query chậm
Không nên để:
dd($request->all());
trên Website thật.
Bởi vì:
dd()
↓
dừng request
↓
hiển thị dữ liệu
Nếu dữ liệu chứa thông tin nhạy cảm, hậu quả có thể nghiêm trọng.
Sau khi Debug xong:
XÓA dd()
Debug phải luôn đi cùng Security.
Không nên Log:
Password
API Secret
Private Key
Credit Card
Authentication Token
Ví dụ không nên:
Log::info(
'Login',
[
'password' => $request->password,
]
);
Thay vào đó chỉ Log thông tin cần thiết:
Log::info(
'User login',
[
'user_id' => $user->id,
]
);
Đến thời điểm này Blog CMS đã có:
Authentication
Authorization
CRUD
Upload
Soft Delete
Scope
Observer
Event
Listener
Queue
Mail
Notification
Cache
Storage
Do đó Debug trở nên đặc biệt quan trọng.
Có thể hình dung:
BLOG CMS
│
┌───────────────┼───────────────┐
│ │ │
Log Debugbar Telescope
│ │ │
▼ ▼ ▼
Application Request Application
Events Queries Activity
Errors Views Jobs
Warning Route Mail
Khi gặp lỗi:
ERROR
│
▼
REPRODUCE
│
▼
INSPECT
│
┌────────┼────────┐
▼ ▼ ▼
Log Debugbar Telescope
│ │ │
└────────┼────────┘
▼
FIND CAUSE
│
▼
FIX
│
▼
TEST
Đây là tư duy Debug quan trọng hơn việc chỉ biết một vài lệnh.
Trong bài này chúng ta đã tìm hiểu:
Logging.
Log::info().
Log::warning().
Log::error().
Log::debug().
Log Context.
Log File.
Log Channel.
Daily Log.
APP_DEBUG.
dd().
dump().
Laravel Debugbar.
Query Debug.
N+1 Query.
Route Debug.
View Debug.
Performance Debug.
Laravel Telescope.
Telescope Requests.
Telescope Queries.
Telescope Exceptions.
Telescope Jobs.
Telescope Mail.
Telescope Notifications.
Debug Production.
Debug Security.
Quy trình Debug Blog CMS.
Trong PostController, ghi Log khi:
Create Post
Update Post
Delete Post
Ví dụ:
Log::info(
'Post created',
[
'post_id' => $post->id,
'user_id' => auth()->id(),
]
);
Sử dụng:
dd($request->all());
để kiểm tra dữ liệu Form Post.
Sau khi kiểm tra xong:
Xóa
dd()khỏi code.
Cài:
composer require barryvdh/laravel-debugbar --dev
Sau đó kiểm tra:
Post Query
Category Query
User Query
Thử:
$posts = Post::all();
sau đó truy cập:
{{ $post->category->name }}
Quan sát Query.
Sau đó sửa thành:
$posts = Post::with('category')->get();
và so sánh.
Cài:
composer require laravel/telescope
Sau đó:
php artisan telescope:install
và:
php artisan migrate
Truy cập Telescope Dashboard trong môi trường Local.
Thử:
Request
Query
Exception
Log
Job
Mail
và quan sát hoạt động của Blog CMS.
Log giúp chúng ta biết ứng dụng đã làm gì.
Debugbar giúp chúng ta nhìn thấy những gì đang xảy ra trong request hiện tại.
Telescope giúp chúng ta quan sát nhiều hoạt động của Laravel từ một Dashboard.
dd()vàdump()rất hữu ích khi học và Debug Local, nhưng phải loại bỏ khỏi Production code.
APP_DEBUG=true chỉ nên sử dụng trong môi trường phát triển.
Production phải sử dụng
APP_DEBUG=false.
Một Developer Laravel không chỉ cần biết viết:
$post->update();
mà còn phải biết trả lời:
Tại sao nó không update?
Query nào chạy?
Request có dữ liệu không?
Validation có đúng không?
Exception nằm ở đâu?
Database có vấn đề không?
Queue có chạy không?
File có tồn tại không?
Đó chính là lúc Logging và Debugging trở thành kỹ năng bắt buộc.
Với Blog CMS, chúng ta đã đi từ:
PHP
↓
Laravel
↓
Database
↓
Eloquent
↓
CRUD
↓
Authentication
↓
Authorization
↓
Advanced Laravel
↓
Logging & Debug
và đã hoàn thành Phần 8 — Nâng cao.
Bài tiếp theo: Bài 41 — REST API, bắt đầu Phần 9 — REST API, nơi Blog CMS sẽ mở dữ liệu cho các ứng dụng khác thông qua HTTP API.
x1
quay về MỤC LỤC
Cập nhật: 2026-09-16T11:27:55.455+07:00
Storage là hệ thống quản lý file của Laravel.
Thay vì tự xử lý đường dẫn file bằng PHP thuần, Laravel cung cấp
Storageđể làm việc với upload, lưu trữ, đọc, xóa, di chuyển và tạo URL cho file một cách thống nhất.
Trong Blog CMS của chúng ta, Storage đặc biệt quan trọng vì hệ thống có:
Upload ảnh bài viết.
Hiển thị ảnh bài viết.
Thay thế ảnh cũ.
Xóa ảnh khi xóa Post.
Lưu đường dẫn ảnh vào Database.
Quản lý file public/private.
Sau này có thể chuyển từ Local Storage sang S3 hoặc dịch vụ tương thích S3.
Laravel 13 sử dụng filesystem abstraction dựa trên Flysystem, cho phép ứng dụng sử dụng cùng một API khi làm việc với Local, SFTP, Amazon S3 và các filesystem tương thích S3.
📌 Ghi chú:
🚀 Nếu đã upload mã nguồn lên host thật, nên chọn Amazon S3 ngay từ đầu (vì số lượng 📷 ảnh và 🎬 videos chèn trong posts sẽ rất lớn trong tương lai) 👉 xem mục 35.
🛠️ Mã nguồn hoàn chỉnh CRUD post có kèm upload ảnh đã có sẵn trong Bài 20 — Upload File 📤 và Bài 27 — CRUD Posts 📝.
Trong PHP thuần, nếu muốn upload file, chúng ta thường phải làm việc trực tiếp với:
move_uploaded_file()
và tự xử lý:
đường dẫn
tên file
thư mục
quyền truy cập
URL
xóa file
Laravel đơn giản hóa toàn bộ quá trình này thông qua:
use Illuminate\Support\Facades\Storage;
Ví dụ:
Storage::put(
'example.txt',
'Hello Laravel 13'
);
Laravel sẽ xử lý việc lưu file thông qua filesystem đang được cấu hình.
File cấu hình chính:
config/filesystems.php
Có thể hình dung:
Laravel Application
│
▼
Storage API
│
┌──────┼───────────┐
▼ ▼ ▼
Local SFTP S3
Điểm quan trọng:
Code Laravel có thể gần như không thay đổi khi chuyển nơi lưu trữ file.
Ví dụ:
Storage::put('photos/image.jpg', $content);
Sau này có thể chuyển từ Local sang S3 bằng cấu hình disk thay vì viết lại toàn bộ logic upload.
Laravel gọi mỗi nơi lưu trữ là một Disk.
Ví dụ:
local
public
s3
Có thể hình dung:
Storage
│
├── local
│
├── public
│
└── s3
Khi gọi:
Storage::put(...)
Laravel sử dụng default disk.
Nếu muốn chỉ rõ disk:
Storage::disk('public')->put(...);
Trong Laravel 13, local disk mặc định lưu file tương đối với:
storage/app/private
Ví dụ:
Storage::disk('local')->put(
'example.txt',
'Hello Laravel'
);
File sẽ nằm trong vùng private của ứng dụng.
Theo cấu hình filesystem mặc định của Laravel 13, local driver sử dụng storage/app/private.
Nếu file cần được trình duyệt truy cập trực tiếp, Laravel cung cấp:
public
disk.
Mặc định:
storage/app/public
Ví dụ:
Storage::disk('public')->put(
'images/example.jpg',
$content
);
File sẽ được lưu vào:
storage/app/public/images/example.jpg
Nhưng chỉ lưu file ở đây chưa đủ.
Để file trong:
storage/app/public
có thể truy cập thông qua web, Laravel sử dụng symbolic link:
public/storage
↓
storage/app/public
Tạo link:
php artisan storage:link
Sau đó:
public/storage
sẽ trỏ tới:
storage/app/public
Laravel khuyến nghị sử dụng symbolic link này để những file public trong storage/app/public có thể được truy cập từ web.
Sau khi chạy:
php artisan storage:link
có thể hình dung:
project/
│
├── app/
├── public/
│ ├── index.php
│ └── storage/
│
├── storage/
│ └── app/
│ ├── private/
│ └── public/
│ ├── images/
│ └── posts/
│
└── config/
└── filesystems.php
Quan hệ:
public/storage
│
▼
storage/app/public
Import:
use Illuminate\Support\Facades\Storage;
Ví dụ:
Storage::put(
'hello.txt',
'Laravel 13'
);
Đọc file:
$content = Storage::get('hello.txt');
Kiểm tra file:
if (Storage::exists('hello.txt')) {
// File tồn tại
}
Ví dụ:
Storage::put(
'documents/example.txt',
'Hello Laravel 13'
);
Nếu sử dụng:
Storage::disk('public')->put(
'documents/example.txt',
'Hello Laravel 13'
);
file sẽ nằm trong public disk.
Đây là phần quan trọng nhất đối với Blog CMS.
Giả sử Form:
<form
method="POST"
enctype="multipart/form-data"
>
@csrf
<input
type="file"
name="image"
>
<button type="submit">
Upload
</button>
</form>
Trong Controller:
$image = $request->file('image');
$path = $image->store('posts', 'public');
Laravel sẽ lưu file vào:
storage/app/public/posts/
và trả về path, ví dụ:
posts/abc123.jpg
Laravel sử dụng tên file duy nhất khi store() được gọi, đồng thời xác định extension dựa trên MIME type của file.
Ví dụ Controller:
if ($request->hasFile('image')) {
$path = $request->file('image')
->store('posts', 'public');
$post->image = $path;
}
Database chỉ cần lưu:
posts/abc123.jpg
Không nên lưu toàn bộ:
D:\www\...
hoặc:
http://localhost/...
Database nên lưu relative path.
Ví dụ:
posts/abc123.jpg
Giả sử:
Database
image
----------------------
posts/abc123.jpg
Local:
storage/app/public/posts/abc123.jpg
Sau này chuyển sang S3:
S3
└── posts/abc123.jpg
Database vẫn có thể giữ:
posts/abc123.jpg
Code chỉ cần thay đổi disk hoặc cấu hình Storage.
Đây là một trong những lợi ích lớn của filesystem abstraction.
Sau khi chạy:
php artisan storage:link
có thể sử dụng:
<img
src="{{ Storage::url($post->image) }}"
alt="{{ $post->title }}"
>
Import trong Blade:
@php
use Illuminate\Support\Facades\Storage;
@endphp
Hoặc trong nhiều trường hợp có thể dùng:
<img
src="{{ asset('storage/' . $post->image) }}"
alt="{{ $post->title }}"
>
Tuy nhiên:
Storage::url()
là cách phù hợp hơn khi muốn code phụ thuộc vào filesystem disk thay vì hard-code đường dẫn /storage. Laravel cung cấp url() để tạo URL tương ứng với disk đang sử dụng.
Ví dụ:
$url = Storage::url(
'posts/abc123.jpg'
);
Với local public disk, URL thường có dạng:
/storage/posts/abc123.jpg
Với S3:
https://bucket.example.com/posts/abc123.jpg
Như vậy Controller/Blade không nhất thiết phải biết file đang nằm ở Local hay Cloud.
store() tự tạo tên file.
Nếu muốn tự đặt tên:
$path = $request->file('image')
->storeAs(
'posts',
'my-post.jpg',
'public'
);
Kết quả:
storage/app/public/posts/my-post.jpg
Cấu trúc:
storeAs(
$directory,
$filename,
$disk
);
Laravel cũng cung cấp:
$file->hashName();
Ví dụ:
$name = $request->file('image')->hashName();
Kết quả có thể giống:
X7k8p9Lm2.jpg
Đây là một cách tránh các vấn đề khi người dùng upload nhiều file có cùng tên.
Có thể lấy extension:
$extension = $file->extension();
Ví dụ:
jpg
hoặc:
png
Laravel xác định extension dựa trên MIME type thay vì chỉ tin vào tên file do client gửi lên.
Có thể sử dụng:
$mime = Storage::mimeType(
$post->image
);
Ví dụ:
image/jpeg
image/png
if (Storage::disk('public')->exists(
$post->image
)) {
// File tồn tại
}
Điều này rất hữu ích trước khi:
Xóa file.
Đọc file.
Hiển thị file.
Kiểm tra file cũ trước khi thay thế.
Để xóa:
Storage::disk('public')->delete(
$post->image
);
Ví dụ:
if ($post->image) {
Storage::disk('public')->delete(
$post->image
);
}
Đây chính là logic phù hợp với Blog CMS của chúng ta.
Đây là trường hợp rất thực tế.
Admin đang có:
posts/old-image.jpg
Sau đó upload:
posts/new-image.jpg
Nếu chỉ update Database:
Database
↓
new-image.jpg
thì:
old-image.jpg
vẫn nằm trong Storage.
Kết quả:
Database
↓
new-image.jpg
Storage
├── old-image.jpg ← rác
└── new-image.jpg
Do đó phải xóa ảnh cũ.
use Illuminate\Support\Facades\Storage;
public function update(Request $request, Post $post)
{
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255'],
'excerpt' => ['nullable', 'string'],
'content' => ['required', 'string'],
'image' => [
'nullable',
'image',
'max:2048',
],
]);
if ($request->hasFile('image')) {
if ($post->image) {
Storage::disk('public')
->delete($post->image);
}
$validated['image'] = $request
->file('image')
->store('posts', 'public');
}
$post->update($validated);
return redirect()
->route('posts.index')
->with(
'success',
'Cập nhật bài viết thành công.'
);
}
Ở đây:
'max:2048'
tương đương:
2048 KB ≈ 2 MB
Khi xóa Post:
public function destroy(Post $post)
{
if ($post->image) {
Storage::disk('public')
->delete($post->image);
}
$post->delete();
return redirect()
->route('posts.index')
->with(
'success',
'Xóa bài viết thành công.'
);
}
Điểm quan trọng:
if ($post->image)
để tránh cố gắng xóa một path rỗng hoặc null.
Có thể copy:
Storage::copy(
'posts/old.jpg',
'posts/new.jpg'
);
Hoặc chỉ rõ disk:
Storage::disk('public')->copy(
'posts/old.jpg',
'posts/new.jpg'
);
Di chuyển:
Storage::move(
'posts/old.jpg',
'posts/new.jpg'
);
Ví dụ:
posts/draft/image.jpg
sang:
posts/published/image.jpg
Lấy danh sách file trong một thư mục:
$files = Storage::files('posts');
Nếu muốn lấy cả file trong thư mục con:
$files = Storage::allFiles('posts');
Laravel cung cấp cả files() và allFiles() cho việc liệt kê file.
Tạo thư mục:
Storage::makeDirectory('posts');
Xóa thư mục:
Storage::deleteDirectory('posts');
Lấy danh sách directory:
$directories = Storage::directories('posts');
Hoặc toàn bộ directory con:
$directories = Storage::allDirectories('posts');
Có thể lấy kích thước file:
$size = Storage::size(
'posts/example.jpg'
);
Kết quả tính bằng:
bytes
Ví dụ:
204800
Có thể chuyển thành:
200 KB
Lấy thời điểm file được chỉnh sửa:
$time = Storage::lastModified(
'posts/example.jpg'
);
Kết quả là UNIX timestamp.
Laravel có khái niệm:
public
private
File có thể được người khác truy cập.
Ví dụ:
Ảnh bài viết
Ảnh sản phẩm
Avatar
File không nên được truy cập trực tiếp.
Ví dụ:
Tài liệu cá nhân
File nội bộ
File download cần kiểm tra quyền
Laravel filesystem abstraction sử dụng visibility để biểu diễn quyền truy cập file trên các backend khác nhau.
Ví dụ:
Storage::put(
'posts/example.jpg',
$content,
'public'
);
Hoặc:
Storage::disk('public')->put(
'posts/example.jpg',
$content
);
Không phải tất cả file đều nên nằm trong:
storage/app/public
Nếu file nhạy cảm hoặc cần kiểm tra quyền trước khi download, có thể sử dụng private storage.
Ví dụ:
storage/app/private/documents/
Controller kiểm tra quyền:
if (! $user->can('download', $document)) {
abort(403);
}
Sau đó mới trả file cho người dùng.
Laravel có thể trả file download thông qua:
return Storage::download(
'documents/example.pdf'
);
Có thể chỉ định tên file download:
return Storage::download(
'documents/example.pdf',
'tai-lieu.pdf'
);
Đây là một mô hình phù hợp cho:
Private files
↓
Authorization
↓
Download
thay vì để file public trực tiếp.
Laravel hỗ trợ URL có thời hạn:
$url = Storage::temporaryUrl(
'file.jpg',
now()->addMinutes(5)
);
Ví dụ:
URL
↓
có hiệu lực 5 phút
↓
hết hạn
↓
không truy cập được nữa
Điều này đặc biệt hữu ích với file private hoặc cloud storage. Laravel 13 hỗ trợ temporary URLs cho local và S3 trong các cấu hình phù hợp.
Khi Website lớn hơn, có thể chuyển file sang:
Amazon S3
Laravel hỗ trợ S3 thông qua Flysystem.
Cài package:
composer require league/flysystem-aws-s3-v3 "^3.0" --with-all-dependencies
Sau đó cấu hình:
AWS_ACCESS_KEY_ID=your-key
AWS_SECRET_ACCESS_KEY=your-secret
AWS_DEFAULT_REGION=your-region
AWS_BUCKET=your-bucket
AWS_USE_PATH_STYLE_ENDPOINT=false
Sau đó:
FILESYSTEM_DISK=s3
Code upload có thể vẫn rất giống:
$path = $request->file('image')
->store('posts', 's3');
Đây chính là lợi ích của filesystem abstraction.
Không chỉ Amazon S3.
Laravel 13 có thể làm việc với các storage tương thích S3 như:
Cloudflare R2
DigitalOcean Spaces
Vultr Object Storage
Hetzner Cloud Storage
RustFS
Thông thường chỉ cần cấu hình credentials và endpoint phù hợp.
Quy trình của Blog CMS:
User
│
▼
Form Upload
│
▼
Validation
│
▼
UploadedFile
│
▼
Storage
│
▼
storage/app/public/posts
│
▼
Database
│
└── posts/abc123.jpg
Database:
posts.image
↓
posts/abc123.jpg
Storage:
storage/app/public/posts/abc123.jpg
URL:
/storage/posts/abc123.jpg
Không nên thiết kế:
posts
----------------
image = BINARY DATA
cho ảnh Blog thông thường.
Thay vào đó:
posts
----------------
image = posts/abc123.jpg
File:
Storage
↓
posts/abc123.jpg
Database chỉ quản lý:
Path
Storage quản lý:
File
Storage không thay thế Validation.
Trước khi lưu file:
$request->validate([
'image' => [
'nullable',
'image',
'max:2048',
],
]);
Sau đó:
$request->file('image')
->store('posts', 'public');
Quy trình:
Upload
↓
Validation
↓
Storage
Không nên:
Upload
↓
Storage ngay
mà không kiểm tra file.
Trong project hiện tại, Post có:
image
nên quy ước có thể là:
storage/app/public/posts/
Ví dụ:
posts/
├── a8d91.jpg
├── b73k2.png
└── c91mx.webp
Database:
posts.image
chỉ lưu:
posts/a8d91.jpg
Khi hiển thị:
<img
src="{{ Storage::url($post->image) }}"
alt="{{ $post->title }}"
>
Khi thay ảnh:
Xóa ảnh cũ
↓
Upload ảnh mới
↓
Lưu path mới
Khi xóa Post:
Xóa file
↓
Xóa Database record
Ví dụ phần Store:
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
public function store(Request $request)
{
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255'],
'excerpt' => ['nullable', 'string'],
'content' => ['required', 'string'],
'category_id' => [
'nullable',
'exists:categories,id',
],
'image' => [
'nullable',
'image',
'max:2048',
],
]);
if ($request->hasFile('image')) {
$validated['image'] = $request
->file('image')
->store('posts', 'public');
}
$validated['user_id'] = $request->user()->id;
Post::create($validated);
return redirect()
->route('posts.index')
->with(
'success',
'Tạo bài viết thành công.'
);
}
Đây là flow hoàn chỉnh:
Validate
↓
Check File
↓
Store File
↓
Get Path
↓
Add user_id
↓
Create Post
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
public function update(
Request $request,
Post $post
) {
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255'],
'excerpt' => ['nullable', 'string'],
'content' => ['required', 'string'],
'category_id' => [
'nullable',
'exists:categories,id',
],
'image' => [
'nullable',
'image',
'max:2048',
],
]);
if ($request->hasFile('image')) {
if ($post->image) {
Storage::disk('public')
->delete($post->image);
}
$validated['image'] = $request
->file('image')
->store('posts', 'public');
}
$post->update($validated);
return redirect()
->route('posts.index')
->with(
'success',
'Cập nhật bài viết thành công.'
);
}
use Illuminate\Support\Facades\Storage;
public function destroy(Post $post)
{
if ($post->image) {
Storage::disk('public')
->delete($post->image);
}
$post->delete();
return redirect()
->route('posts.index')
->with(
'success',
'Xóa bài viết thành công.'
);
}
Đây là pattern mà Blog CMS nên sử dụng:
Delete Database
+
Delete Physical File
tránh tình trạng:
Database đã xóa
nhưng file vẫn tồn tại
Laravel 13 có thêm một filesystem driver đáng chú ý:
read-through
Mô hình:
Application
│
▼
Primary Disk
│
│ không có file
▼
Fallback Disk
Nếu file chỉ tồn tại ở fallback disk, Laravel có thể đọc file từ đó và đưa bản sao sang primary disk để các request sau sử dụng primary. Đây là tính năng hữu ích khi muốn di chuyển file giữa các storage mà không phải downtime.
Đây là nội dung nâng cao.
Người mới học Storage chưa cần sử dụng ngay.
Laravel cũng hỗ trợ Fake Storage để kiểm thử upload.
Ví dụ:
Storage::fake('photos');
Sau đó test upload:
Storage::disk('photos')
->assertExists('photo.jpg');
Laravel cung cấp Storage::fake() để tạo filesystem giả, giúp kiểm thử upload mà không cần ghi file thật vào storage.
Phần này sẽ được giới thiệu lại kỹ hơn trong Bonus PHPUnit của khóa học.
Upload thành công nhưng:
ảnh không hiển thị
Kiểm tra:
php artisan storage:link
Lưu:
Storage::disk('public')
nhưng lại lấy:
Storage::disk('local')
Có thể dẫn tới không tìm thấy file.
Không nên lưu:
/storage/posts/image.jpg
hoặc:
C:\xampp\...
Nên lưu:
posts/image.jpg
$post->delete();
nhưng không:
Storage::disk('public')
->delete($post->image);
Kết quả:
Database
↓
file đã mất record
Storage
↓
file rác vẫn còn
Khi Update Post:
old.jpg
new.jpg
Nếu không xóa:
old.jpg
Storage sẽ ngày càng phình to.
| Phương thức | Công dụng |
|---|---|
Storage::put() | Lưu nội dung |
Storage::get() | Đọc file |
Storage::exists() | Kiểm tra tồn tại |
Storage::delete() | Xóa file |
Storage::copy() | Copy file |
Storage::move() | Di chuyển file |
Storage::url() | Tạo URL |
Storage::download() | Download |
Storage::temporaryUrl() | URL có thời hạn |
Storage::files() | Liệt kê file |
Storage::allFiles() | Liệt kê toàn bộ file |
Storage::makeDirectory() | Tạo thư mục |
Storage::deleteDirectory() | Xóa thư mục |
Storage::size() | Kích thước file |
Storage::mimeType() | MIME type |
Storage::lastModified() | Thời gian sửa cuối |
$file->store() | Upload file |
$file->storeAs() | Upload với tên chỉ định |
Trong bài này chúng ta đã tìm hiểu:
Storage là gì.
Filesystem của Laravel.
Disk.
Local Storage.
Public Storage.
storage:link.
Storage Facade.
put().
get().
exists().
store().
storeAs().
hashName().
Upload Image.
Storage URL.
Delete File.
Copy File.
Move File.
Directory.
File Metadata.
Public / Private Visibility.
Download.
Temporary URL.
Amazon S3.
S3-Compatible Storage.
Storage Testing.
Read-Through Filesystem trong Laravel 13.
Ứng dụng Storage vào Blog CMS.
Tạo Form:
<input
type="file"
name="image"
>
Upload vào:
storage/app/public/posts
Sử dụng:
Storage::url($post->image)
để hiển thị ảnh trong Blog.
Khi Admin upload ảnh mới:
Xóa ảnh cũ
↓
Upload ảnh mới
↓
Update Database
Khi xóa Post:
Xóa Image
↓
Xóa Post
Chạy:
php artisan storage:link
Sau đó kiểm tra:
storage/app/public
và:
public/storage
Database lưu thông tin về file.
Storage lưu file thật.
Public Disk dành cho những file cần truy cập công khai.
Private Storage dành cho những file cần kiểm soát quyền truy cập.
Khi thay hoặc xóa file, đừng quên xử lý file vật lý trong Storage.
Đối với Blog CMS:
POST
│
┌──────────┴──────────┐
│ │
Database Storage
│ │
▼ ▼
image = posts/a.jpg posts/a.jpg
Đây là cách Laravel tách biệt:
DATA
và:
FILES
giúp ứng dụng dễ bảo trì và sau này có thể chuyển từ Local Storage sang Cloud Storage mà không phải viết lại toàn bộ chức năng upload.
Bài tiếp theo: Bài 40 — Logging & Debug, tìm hiểu Log, Debugbar và Telescope để theo dõi, debug và quan sát ứng dụng Laravel trong quá trình phát triển.
x1
quay về MỤC LỤC
Cập nhật: 2026-09-15T11:40:05.231+07:00
Trong quy trình phát triển Laravel tiêu chuẩn (từ Laravel 9 trở đi và hiện tại là Laravel 13), vị trí đặt các file .js và .css do developer tự viết được phân chia rõ ràng theo mục đích sử dụng:
Tất cả các file JS/CSS do developer tự định nghĩa, chưa qua đóng gói/biên dịch sẽ đặt tại thư mục resources/
my-laravel-project/
├── resources/
│ ├── css/
│ │ ├── app.css # File CSS chính
│ │ └── custom.css # CSS tự định nghĩa thêm
│ └── js/
│ ├── app.js # File JS chính (entry point)
│ ├── components/ # Chứa các module JS/Vue/React
│ └── custom.js # JS tự viết
Vì sao đặt ở resources/?
Tài liệu Official (Trang chủ Laravel) quy định resources/css và resources/js là nơi lưu trữ tất cả frontend assets.
Các file này sẽ được Vite (công cụ đóng gói mặc định) đọc, nén, tối ưu hóa (bundle/minify) và tạo mã hash để chống cache trình duyệt trước khi phát hành (production).
Cách liên kết vào Blade Layout:
Trình biên dịch Vite sẽ biên dịch các file từ resources/ và nhúng vào HTML thông qua directive @vite():
<!-- resources/views/layouts/app.blade.php -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
Nếu có các file .js hoặc .css thuần (như library bên ngoài hoặc file script legacy) không qua trình đóng gói Vite, developer sẽ đặt trực tiếp tại thư mục public/:
my-laravel-project/
├── public/
│ ├── css/
│ │ └── static-style.css
│ └── js/
│ └── static-script.js
Cách nhúng vào Blade Layout:
Nhúng trực tiếp bằng thẻ HTML thông thường qua helper asset():
<link rel="stylesheet" href="{{ asset('css/static-style.css') }}">
<script src="{{ asset('js/static-script.js') }}"></script>
Khi tham khảo các repository lớn trên GitHub (như Laravel Breeze, Laravel Jetstream, hay các project mã nguồn mở chuẩn):
Trong resources/js/, họ không viết chung trong 1 file app.js lớn mà chia nhỏ thành các thư mục như components/, pages/, services/, utils/.
Thay vì file .css thuần, dự án chuyên nghiệp thường dùng Tailwind CSS hoặc Sass/SCSS (resources/css/app.css hoặc resources/scss/app.scss).
Mọi file JS/CSS đóng vai trò là "Entry Point" (file gốc) đều phải khai báo trong file vite.config.js:
// vite.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
export default defineConfig({
plugins: [
laravel([
'resources/css/app.css',
'resources/js/app.js',
]),
],
});
File tự viết cần compile/bundle (Khuyên dùng) → resources/css/ và resources/js/.
File static dùng trực tiếp (Không qua Vite) → public/css/ và public/js/.
Tạo file tại đường dẫn: resources/js/custom.js
// resources/js/custom.js
console.log("File custom.js đã chạy thành công!");
document.addEventListener('DOMContentLoaded', function () {
const btn = document.getElementById('myBtn');
if (btn) {
btn.addEventListener('click', function () {
alert('Bạn vừa bấm nút!');
});
}
});
nếu file custom.js là 01 class thì phải thêm đúng 1 dòng này ở cuối file để xuất class ra phạm vi toàn cục.
Lí do: Vì Vite đóng gói JS dưới dạng ES Module (mọi thứ bị nhốt riêng bên trong file), nếu không đẩy nó ra window.ImageCoordinateTracker = ..., đoạn <script> ở Blade sẽ không bao giờ nhìn thấy Class đó và báo lỗi Uncaught ReferenceError.
class ImageCoordinateTracker {
constructor(config) {
// ... code khởi tạo hiện tại của bạn ...
}
}
// Gán vào window để Blade ở đâu cũng gọi được
window.ImageCoordinateTracker = ImageCoordinateTracker;Mở file vite.config.js ở thư mục gốc của dự án và thêm đường dẫn file custom.js vào mảng input:
// vite.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
export default defineConfig({
plugins: [
laravel({
input: [
'resources/css/app.css',
'resources/js/app.js',
'resources/js/custom.js', // THÊM DÒNG NÀY
],
refresh: true,
}),
],
});
Thêm chỉ thị @vite() chứa file custom.js vào thẻ <head> hoặc trước khi đóng thẻ </body> trong giao diện Blade:
<!-- resources/views/welcome.blade.php (hoặc file layout của bạn) -->
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<title>Ví dụ chèn JS trong Laravel</title>
<!-- Nhúng CSS chính và file custom.js -->
@vite(['resources/css/app.css', 'resources/js/app.js', 'resources/js/custom.js'])
</head>
<body>
<h1>Trang web Laravel</h1>
<button id="myBtn">Click thử</button>
</body>
</html>
Khi phát triển (Development Mode), bạn bật terminal ở thư mục dự án và chạy:
npm run dev
Lưu ý: Nếu deploy lên server thật (Production), bạn cần chạy lệnh npm run build để Vite đóng gói và tối ưu file custom.js.
Dưới đây là cách thực hiện nếu bạn vẫn muốn làm, cùng với lý do vì sao cách này tồn tại nhiều rủi ro.
Cách làm (Nếu làm theo ý bạn)
Chạy lệnh đóng gói ở Local:
Mở Terminal tại thư mục dự án bên máy bạn và chạy:
npm run build
Kiểm tra thư mục đầu ra:
Lệnh trên sẽ tạo ra thư mục public/build/. Thư mục này chứa tất cả file CSS/JS đã được nén, tối ưu và đặt tên theo mã hash (ví dụ: custom-A1b2C3.js).
Upload lên Host:
Sử dụng FTP (FileZilla) hoặc cPanel File Manager để upload toàn bộ thư mục public/build/ từ máy bạn lên host thật.
Bỏ qua Node.js trên Host:
Host thật không cần cài Node.js hay npm vẫn chạy được giao diện bình thường.
Rủi ro & Nhược điểm cần lưu ý
Xung đột môi trường (Environment Mismatch): File .env ở Local và Production thường khác nhau (ví dụ: APP_URL). Một số thư viện JavaScript đọc cấu hình từ file .env lúc build. Nếu build ở Local, nó sẽ mang theo cấu hình Local lên Production.
Tốn dung lượng Git (Nếu dùng Git): Nếu bạn đẩy cả thư mục public/build/ lên GitHub/GitLab, file lưu trữ sẽ phình to rất nhanh vì mỗi lần rebuild lại tạo ra các file mã hash mới.
Quên upload: Rất dễ xảy ra trường hợp bạn sửa file custom.js, chạy npm run build nhưng quên upload thư mục public/build/ mới lên host, khiến giao diện thực tế không thay đổi.
Chuẩn Pro / CI-CD làm như thế nào?
Trên các dự án thực tế, người ta áp dụng một trong hai quy trình sau:
Dùng SSH chạy trên Host:
Upload mã nguồn (bao gồm file gốc resources/js/custom.js), mở SSH trên Host và chạy trực tiếp:
npm install && npm run build
Dùng CI/CD Automation (GitHub Actions / GitLab CI):
Developer chỉ cần git push mã nguồn lên.
Server CI/CD tự động bật một môi trường ảo, tự chạy npm run build, sau đó tự đẩy thư mục public/build/ sang Production Server.
Cập nhật: 2026-09-15T17:37:52.028+07:00
Cache là một trong những kỹ thuật quan trọng để tăng tốc ứng dụng web.
Thay vì mỗi request đều phải truy vấn Database hoặc thực hiện lại một công việc tốn thời gian, Laravel có thể lưu kết quả vào Cache và sử dụng lại trong những request tiếp theo.
Trong các dự án thực tế, Cache đặc biệt hữu ích với:
Danh mục sản phẩm.
Danh sách bài viết.
Thông tin cấu hình.
Thống kê.
Dữ liệu được truy vấn thường xuyên.
API response.
Những phép tính hoặc truy vấn Database tốn thời gian.
Ghi chú:&
Giả sử Blog CMS của chúng ta có đoạn code:
$categories = Category::active()
->withCount('posts')
->orderBy('name')
->get();
Mỗi lần người dùng truy cập Blog:
Request
↓
Controller
↓
Database
↓
Query categories
↓
Render View
Nếu có 1.000 người truy cập, câu query này có thể được thực hiện rất nhiều lần.
Trong khi danh mục Blog có thể chỉ thay đổi vài lần trong ngày.
Ta có thể Cache kết quả:
Request
↓
Cache?
┌─┴─────────┐
│ │
Có Không
│ │
↓ ↓
Cache Database
│ │
└─────┬─────┘
↓
View
Lần đầu:
Request
↓
Cache không có
↓
Database
↓
Lưu kết quả vào Cache
↓
Response
Những lần sau:
Request
↓
Cache có dữ liệu
↓
Lấy từ Cache
↓
Response
Mục tiêu của Cache không phải là thay thế Database.
Cache là một lớp lưu trữ tạm thời nằm giữa Application và nguồn dữ liệu.
Laravel cung cấp một API thống nhất cho Cache.
Ta có thể sử dụng:
use Illuminate\Support\Facades\Cache;
Sau đó:
Cache::get('key');
Điểm quan trọng là Controller không cần quan tâm quá nhiều đến Cache đang được lưu ở đâu.
Laravel cung cấp nhiều Cache driver khác nhau.
Ví dụ:
Laravel Application
│
▼
Cache API
│
┌──────┼─────────┐
▼ ▼ ▼
Database Redis Memcached
Theo tài liệu Laravel, Cache có thể sử dụng các backend phổ biến như Database, Redis, Memcached và một số driver khác.
Cấu hình Cache nằm trong:
config/cache.php
Laravel sử dụng biến môi trường:
CACHE_STORE=database
Tùy cấu hình project, bạn có thể sử dụng Cache store khác.
Ví dụ:
CACHE_STORE=database
hoặc:
CACHE_STORE=redis
Sau khi thay đổi .env, nếu ứng dụng đang Cache configuration thì có thể chạy:
php artisan config:clear
Một lựa chọn thuận tiện khi học Laravel là sử dụng Database Cache.
Nếu project chưa có bảng Cache, tạo migration:
php artisan make:cache-table
Sau đó:
php artisan migrate
Laravel sẽ tạo bảng dùng để lưu dữ liệu Cache.
Ví dụ có thể hình dung:
Database
users
categories
posts
cache
cache_locks
Cache Database phù hợp cho môi trường học tập hoặc những ứng dụng chưa cần Redis.
Để lưu một giá trị vào Cache:
use Illuminate\Support\Facades\Cache;
Cache::put('name', 'Laravel 13', 60);
Trong đó:
name
↓
Cache Key
Laravel 13
↓
Cache Value
60
↓
TTL - thời gian sống
Sau 60 giây, Cache hết hạn.
Ta cũng có thể sử dụng DateTime:
Cache::put(
'name',
'Laravel 13',
now()->addMinutes(10)
);
Laravel hỗ trợ cả thời gian tính bằng giây và thời điểm hết hạn cụ thể.
Để lấy dữ liệu:
$value = Cache::get('name');
Nếu Cache tồn tại:
Laravel 13
Nếu Cache không tồn tại:
null
Có thể cung cấp giá trị mặc định:
$value = Cache::get('name', 'Default Value');
Nếu key không tồn tại:
Default Value
Kiểm tra Cache có tồn tại hay không:
if (Cache::has('name')) {
// Cache tồn tại
}
Ví dụ:
if (Cache::has('categories')) {
$categories = Cache::get('categories');
}
Đây là một trong những phương thức quan trọng nhất khi làm Laravel.
Thay vì viết:
if (Cache::has('categories')) {
$categories = Cache::get('categories');
} else {
$categories = Category::active()
->withCount('posts')
->orderBy('name')
->get();
Cache::put(
'categories',
$categories,
3600
);
}
Ta có thể viết ngắn gọn:
$categories = Cache::remember(
'categories',
3600,
function () {
return Category::active()
->withCount('posts')
->orderBy('name')
->get();
}
);
Logic:
Cache::remember()
│
▼
Cache có key?
/ \
Có Không
│ │
▼ ▼
Return Closure
cache ↓
Database
↓
Lưu vào Cache
↓
Return
Đây là pattern rất phổ biến trong các Laravel project thực tế.
Giả sử BlogController đang có:
public function index()
{
$posts = Post::published()
->whereHas('category', fn ($q) => $q->active())
->with(['user', 'category'])
->latest('published_at')
->paginate(10);
$categories = Category::active()
->withCount('posts')
->orderBy('name')
->get();
return view('blog.index', compact(
'posts',
'categories'
));
}
Ta có thể Cache $categories:
use Illuminate\Support\Facades\Cache;
public function index()
{
$posts = Post::published()
->whereHas('category', fn ($q) => $q->active())
->with(['user', 'category'])
->latest('published_at')
->paginate(10);
$categories = Cache::remember(
'blog.categories',
3600,
function () {
return Category::active()
->withCount('posts')
->orderBy('name')
->get();
}
);
return view('blog.index', compact(
'posts',
'categories'
));
}
Ở đây:
'blog.categories'
là Cache Key.
3600
là:
3600 giây = 60 phút
Chú ý: đôi khi sẽ phải ép kiểu về object (có thể do lỗi Serialize của Laravel lúc render view biến obj thành string)
-> nên cài Debugbar để dò lỗi BÀI 40 — LOGGING & DEBUG
-> Telescope&PRO hơn do có dashboard riêng và file cấu hình riêng...
$categories = Cache::remember('categories', 60, function () {
return Category::active()
->withCount([
'posts as total_count' => function ($query) {
$query->published();
}
])
->orderBy('name')
->get()
->toArray(); // ép thành array trước khi cache
});
// rồi convert lại thành Collection of Models
$categories = Category::hydrate($categories);
// kiểm tra kiểu dữ liệu
//dd($categories);
return view('components.blog-categories', compact('categories'));
Cache Key nên được đặt tên rõ ràng.
Ví dụ:
'blog.categories'
'blog.latest_posts'
'blog.popular_posts'
'user.' . $user->id
'post.' . $post->id
Không nên sử dụng những key quá chung chung:
'data'
'list'
'users'
Trong một project lớn, nên tổ chức Cache Key có quy ước.
Ví dụ:
blog.categories
blog.posts.latest
blog.posts.popular
user.1
user.2
post.15
post.16
Ví dụ lấy User:
$user = Cache::remember(
'user.' . $id,
3600,
function () use ($id) {
return User::find($id);
}
);
Hoặc Post:
$post = Cache::remember(
'post.' . $id,
3600,
function () use ($id) {
return Post::with([
'user',
'category'
])->findOrFail($id);
}
);
Đây là cách rất hữu ích đối với những dữ liệu được đọc nhiều nhưng thay đổi ít.
Cache đặc biệt hữu ích khi Query phức tạp.
Ví dụ:
$posts = Cache::remember(
'blog.latest_posts',
600,
function () {
return Post::published()
->with(['user', 'category'])
->latest('published_at')
->limit(10)
->get();
}
);
Ở đây Cache sống:
600 giây
= 10 phút
Nếu trong 10 phút có 100 request:
Request 1 → Database
Request 2 → Cache
Request 3 → Cache
Request 4 → Cache
...
Request 100 → Cache
Thay vì:
100 Database Queries
ta có thể giảm xuống gần:
1 Database Query
+
99 Cache Reads
Đây chính là một trong những mục đích quan trọng nhất của Cache.
Khi dữ liệu thay đổi, đôi khi phải xóa Cache.
Ví dụ:
Cache::forget('blog.categories');
Sau đó request tiếp theo:
Cache không tồn tại
↓
Query Database
↓
Tạo Cache mới
Giả sử Admin sửa Category:
public function update(Request $request, Category $category)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'is_active' => ['required', 'boolean'],
]);
$category->update($validated);
Cache::forget('blog.categories');
return redirect()
->route('categories.index')
->with('success', 'Cập nhật danh mục thành công.');
}
Điều này rất quan trọng.
Bởi vì nếu:
Category Database
↓
đã thay đổi
nhưng:
Cache
↓
vẫn chứa dữ liệu cũ
thì Blog có thể tiếp tục hiển thị dữ liệu cũ.
public function store(Request $request)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'is_active' => ['required', 'boolean'],
]);
Category::create($validated);
Cache::forget('blog.categories');
return redirect()
->route('categories.index')
->with('success', 'Thêm danh mục thành công.');
}
public function destroy(Category $category)
{
$category->delete();
Cache::forget('blog.categories');
return redirect()
->route('categories.index')
->with('success', 'Xóa danh mục thành công.');
}
Như vậy:
Create Category
↓
Forget Cache
Update Category
↓
Forget Cache
Delete Category
↓
Forget Cache
Đây là nguyên tắc rất quan trọng:
Dữ liệu thay đổi → phải nghĩ đến việc Invalidate Cache.
add() chỉ lưu dữ liệu nếu key chưa tồn tại.
Cache::add(
'name',
'Laravel 13',
3600
);
Nếu:
name chưa tồn tại
thì lưu.
Nếu:
name đã tồn tại
thì không ghi đè.
Phương thức này hữu ích trong một số trường hợp cần tạo giá trị một lần.
Cache cũng có thể dùng để tăng một giá trị:
Cache::increment('views');
Hoặc:
Cache::increment('views', 5);
Ví dụ:
views = 10
increment()
↓
views = 11
Tương tự:
Cache::decrement('stock');
Hoặc:
Cache::decrement('stock', 5);
Ví dụ:
stock = 100
decrement(..., 5)
↓
stock = 95
Trong những bài toán liên quan đến counter hoặc giới hạn tài nguyên, các thao tác atomic của Cache có thể hữu ích.
Nếu muốn:
Lấy Cache
+
Xóa Cache
có thể sử dụng:
$value = Cache::pull('name');
Ví dụ:
Cache::put('coupon', 'ABC123', 600);
$coupon = Cache::pull('coupon');
Sau khi pull():
coupon
↓
đã bị xóa
Laravel cũng cung cấp pull() cho trường hợp lấy giá trị rồi xóa khỏi Cache.
Có thể xóa toàn bộ Cache:
Cache::flush();
Cẩn thận với phương thức này.
flush() không quan tâm prefix Cache của ứng dụng và có thể xóa toàn bộ entries trong cache store đang sử dụng. Vì vậy không nên tùy tiện gọi trong code production nếu Cache store được chia sẻ với ứng dụng khác.
Thông thường khi phát triển:
php artisan cache:clear
cũng có thể được sử dụng để xóa application cache.
Ngoài:
Cache::get(...)
Laravel còn cung cấp helper:
cache()
Ví dụ:
$value = cache('name');
Có thể lưu:
cache([
'name' => 'Laravel 13'
], 3600);
Hoặc:
$value = cache()->remember(
'name',
3600,
fn () => 'Laravel 13'
);
Laravel cung cấp cả Cache facade và global cache() helper.
Laravel cung cấp một kỹ thuật rất hữu ích gọi là:
Stale-While-Revalidate
Thông qua:
Cache::flexible()
Ví dụ:
$value = Cache::flexible(
'blog.categories',
[300, 600],
function () {
return Category::active()
->withCount('posts')
->orderBy('name')
->get();
}
);
Hai giá trị:
[300, 600]
có nghĩa:
0 ───────── 300 ───────── 600
Fresh Stale
Trong khoảng:
0 → 300 giây
Cache còn fresh.
Trong khoảng:
300 → 600 giây
Laravel có thể trả dữ liệu cũ trước, sau đó đăng ký việc refresh dữ liệu sau response.
Sau:
600 giây
Cache được xem là hết hạn và dữ liệu cần được tính lại ngay.
Đây là cách giảm tình trạng nhiều request cùng lúc phải chờ một tác vụ tạo Cache mới. Laravel mô tả Cache::flexible() chính là implementation cho pattern stale-while-revalidate.
Laravel 13 bổ sung:
Cache::touch()
Mục đích là gia hạn TTL của Cache hiện tại mà không cần lấy giá trị ra rồi lưu lại. Đây là một điểm mới đáng chú ý khi học Cache trên Laravel 13.
Ví dụ:
Cache::touch(
'blog.categories',
3600
);
Có thể hiểu:
Cache hiện tại
↓
Giữ nguyên value
↓
Gia hạn TTL
Thay vì phải:
get()
↓
put()
Laravel cho phép chọn Cache Store.
Ví dụ:
Cache::store('redis')->get('name');
Hoặc:
Cache::store('database')->get('name');
Điều này cho phép một ứng dụng sử dụng nhiều Cache backend tùy mục đích.
Ví dụ:
Laravel
│
├── database
│
├── redis
│
└── memcached
Trong các hệ thống lớn, Redis là một lựa chọn rất phổ biến.
Ví dụ:
CACHE_STORE=redis
Redis có ưu điểm lớn về tốc độ và phù hợp với những ứng dụng cần đọc/ghi Cache với tần suất cao.
Laravel 13 có hỗ trợ Redis thông qua cấu hình Redis của framework.
Có thể hình dung:
Laravel
│
▼
Redis
│
├── Cache
├── Session
├── Queue
└── Data
Tuy nhiên, khi học Laravel trên máy local, không nhất thiết phải bắt đầu bằng Redis.
Có thể bắt đầu bằng:
Database Cache
Sau đó khi triển khai VPS hoặc hệ thống lớn:
Redis
Đây là vấn đề người mới học Laravel rất dễ nhầm.
Dữ liệu chính thức của hệ thống:
users
posts
categories
orders
products
Dữ liệu tạm thời để tăng tốc:
cached_categories
cached_posts
cached_statistics
Database có tính:
Persistence
Cache có tính:
Temporary
Ví dụ:
DATABASE
Category
Laravel
PHP
MySQL
Cache:
blog.categories
↓
Laravel
PHP
MySQL
Nếu Cache bị xóa:
Không sao.
Ứng dụng có thể lấy lại dữ liệu từ Database và tạo Cache mới.
Một trong những vấn đề khó nhất của Cache là:
Khi nào phải xóa Cache?
Ví dụ:
Database
Category = Laravel
Cache:
blog.categories
Category = Laravel
Admin đổi:
Laravel
thành:
Laravel 13
Database:
Laravel 13
nhưng Cache vẫn:
Laravel
Nếu không invalidate Cache:
Database ≠ Cache
Vì vậy cần:
Cache::forget('blog.categories');
Sau đó request tiếp theo tạo Cache mới.
Với project Blog CMS đang xây dựng, những dữ liệu phù hợp để Cache có thể là:
blog.categories
blog.posts.popular
blog.posts.latest
blog.statistics
user.{id}
post.{id}
Không nên Cache mọi thứ.
Ví dụ danh sách Post có:
paginate(10)
và thay đổi liên tục thì cần cân nhắc kỹ trước khi Cache.
Ghi chú:&
Bài toán paging không nên dùng Laravel để giải vì Datatables đã giải quyết rất ngọt ngào trong BÀI 29 — SEARCH, SORT, FILTER, PAGINATION & DATATABLES mục 26
Controller:
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\Post;
use Illuminate\Support\Facades\Cache;
class BlogController extends Controller
{
public function index()
{
$posts = Post::published()
->whereHas(
'category',
fn ($query) => $query->active()
)
->with(['user', 'category'])
->latest('published_at')
->paginate(10);
$categories = Cache::remember(
'blog.categories',
3600,
function () {
return Category::active()
->withCount('posts')
->orderBy('name')
->get();
}
);
return view(
'blog.index',
compact('posts', 'categories')
);
}
}
Khi Blog được truy cập:
Request
↓
BlogController@index
↓
Posts → Database
↓
Categories
↓
Cache
↓
View
Lần đầu:
Categories
↓
Database
↓
Cache
Các request sau:
Categories
↓
Cache
Nên cân nhắc Cache khi:
Query Database nhiều lần.
Dữ liệu ít thay đổi.
Dữ liệu được nhiều người đọc.
Tính toán dữ liệu tốn CPU.
Gọi API bên ngoài tốn thời gian.
Thống kê cần thời gian xử lý.
Website có lượng truy cập lớn.
Ví dụ:
Category
Site Settings
Popular Posts
Statistics
Exchange Rates
API Response
Không phải dữ liệu nào cũng nên Cache.
Ví dụ:
Thông tin thanh toán
Số dư tài khoản
Dữ liệu thay đổi liên tục
Thông tin cần realtime
Nếu Cache sai cách có thể dẫn tới:
Dữ liệu cũ
↓
Người dùng nhìn thấy
↓
Logic ứng dụng sai
Vì vậy:
Cache phải được thiết kế dựa trên đặc tính của dữ liệu.
Một sai lầm phổ biến là nghĩ:
Cache = Database nhanh hơn
Không chính xác.
Cache thường đóng vai trò:
Application
↓
Cache
↓
Database
Cache giúp giảm số lần phải truy cập Database.
Nhưng Database vẫn là nguồn dữ liệu chính của ứng dụng.
Một ứng dụng Laravel thực tế có thể có kiến trúc:
USER
│
▼
Laravel
│
┌────────┴────────┐
│ │
Cache Database
│
Redis
Khi dữ liệu đã có trong Cache:
User
↓
Laravel
↓
Redis
↓
Response
Nếu Cache miss:
User
↓
Laravel
↓
Redis ❌
↓
Database
↓
Redis ← lưu kết quả
↓
Response
Đây là mô hình rất phổ biến trong các hệ thống web hiện đại.
Trong bài này chúng ta đã tìm hiểu:
Cache là gì.
Cache hoạt động như thế nào.
Cấu hình Cache.
Database Cache.
Cache::put().
Cache::get().
Cache::has().
Cache::remember().
Cache Query Database.
Cache Key.
Cache::forget().
Cache::add().
Cache::increment().
Cache::decrement().
Cache::pull().
Cache::flush().
cache() helper.
Cache::flexible().
Stale-While-Revalidate.
Cache::touch() trong Laravel 13.
Cache Store.
Redis Cache.
Cache Invalidation.
Ứng dụng Cache vào Blog CMS.
Sử dụng:
Cache::remember()
để Cache:
Category::active()
->withCount('posts')
->orderBy('name')
->get();
Thời gian:
60 phút
Trong:
CategoryController
thực hiện:
Cache::forget('blog.categories');
sau khi:
Tạo Category.
Cập nhật Category.
Xóa Category.
Tạo Cache:
post.{id}
và sử dụng:
Cache::remember()
để lấy Post cùng:
user
category
Tạo một Cache:
blog.statistics
chứa:
[
'posts' => ...,
'categories' => ...,
'users' => ...,
]
Thử:
Cache::flexible(
'blog.categories',
[300, 600],
function () {
return Category::active()
->withCount('posts')
->orderBy('name')
->get();
}
);
Quan sát cách Laravel xử lý dữ liệu trong khoảng:
0 → 300 giây
và:
300 → 600 giây
Database lưu dữ liệu chính.
Cache lưu dữ liệu tạm thời để tăng tốc.
Cache Hit → lấy dữ liệu nhanh từ Cache.
Cache Miss → lấy dữ liệu từ nguồn và tạo Cache mới.
Dữ liệu thay đổi → phải nghĩ đến Cache Invalidation.
Với Blog CMS của chúng ta, một ví dụ điển hình là:
Category
↓
Database
↓
Cache
↓
Blog Sidebar
Thay vì mỗi request đều query:
Category::active()
->withCount('posts')
->orderBy('name')
->get();
ta có thể Cache kết quả và chỉ tạo lại khi Cache hết hạn hoặc khi Category thay đổi.
Đó chính là cách Cache bắt đầu trở thành một phần của kiến trúc ứng dụng thực tế, chứ không đơn thuần là một mẹo tăng tốc.
Bài tiếp theo: Bài 39 — Storage, tìm hiểu cách Laravel quản lý file, disk, storage/app, public, upload, URL và Storage facade.
x1
quay về MỤC LỤC
Cập nhật: 2026-09-17T11:15:50.937+07:00
Đây là phần quan trọng nhất của toàn bộ khóa học.
Ở 5 phần trước, chúng ta đã học:
🛒 Tìm sản phẩm
↓
👥 Tìm khách
↓
📱 Làm nội dung
↓
💰 Chạy quảng cáo
↓
🔥 Bán lại cho khách cũ
↓
🌐 Google + Website
↓
🤖 AI + AutomationNhưng tất cả những thứ đó vẫn chỉ là kiến thức nếu chưa có một sản phẩm thật, khách hàng thật và giao dịch thật.
Vì vậy, ở Phần 6 chúng ta sẽ:
Đưa toàn bộ hệ thống vào thực tế.
Không còn hỏi:
"Nếu bán thì sao?"
Mà bắt đầu hỏi:
"Hôm nay tôi bán được bao nhiêu?"
Đừng bắt đầu bằng:
"Tôi muốn xây một thương hiệu lớn."
Hãy bắt đầu nhỏ hơn:
"Tôi sẽ thử bán một sản phẩm."
Sản phẩm đầu tiên nên tương đối đơn giản.
Có thể bắt đầu bằng một sản phẩm:
Dễ hiểu.
Dễ trình bày bằng hình ảnh/video.
Có nhu cầu rõ ràng.
Có mức giá phù hợp với khách hàng mục tiêu.
Có biên lợi nhuận đủ để thử nghiệm.
Có nguồn hàng ổn định.
Không quá phức tạp về vận chuyển hoặc bảo hành.
Ví dụ bạn nhìn thấy một sản phẩm rất đẹp:
😍 Đẹp
🔥 Đang hot
📱 TikTok nhiều người nóiĐiều đó chưa đủ.
Hãy đặt câu hỏi:
Có người cần không?
↓
Có người trả tiền không?
↓
Giá bán bao nhiêu?
↓
Giá vốn bao nhiêu?
↓
Còn lợi nhuận không?
↓
Có thể tìm khách ở đâu?
↓
Có thể bán lại không?Nếu câu trả lời tương đối rõ ràng:
Có thể test.
Ví dụ:
Giá bán: 300.000đ
Giá vốn: 150.000đ
Đóng gói: 10.000đ
Phí nền tảng / thanh toán: 15.000đ
Chi phí khác: 15.000đ
----------------------------------
Còn lại trước quảng cáo: 110.000đNhư vậy bạn biết:
Mỗi đơn có khoảng 110.000đ để trang trải chi phí thu hút khách và tạo lợi nhuận.
Nếu chi phí để có một đơn hàng là:
50.000đthì còn:
110K - 50K
= 60KĐây mới là con số đáng quan tâm.
Không cần xây một hệ thống khổng lồ ngay từ đầu.
Bạn có thể bắt đầu:
SẢN PHẨM
↓
📱 TikTok / Facebook
↓
💬 Tin nhắn
↓
🛒 Đơn hàngHoặc:
SẢN PHẨM
↓
🌐 Website
↓
🛒 Đặt hàngHoặc:
GOOGLE
↓
🌐 Website
↓
💰 Đơn hàngKhông cần làm tất cả cùng lúc.
Một sản phẩm + một nhóm khách + một kênh chính là đủ để bắt đầu.
Trước khi đưa sản phẩm ra thị trường, hãy chuẩn bị:
📦 Sản phẩm
↓
🖼️ Ảnh
↓
🎬 Video
↓
✍️ Nội dung
↓
💰 Giá
↓
🎁 Ưu đãi
↓
💬 Cách liên hệ
↓
🛒 Cách đặt hàng
↓
🚚 Cách giao hàngNếu khách nhìn thấy sản phẩm nhưng không biết:
"Mua ở đâu?"
thì bạn đã mất một cơ hội bán hàng.
Đây là lúc những gì học ở Phần 1 và Phần 5 được sử dụng.
Ví dụ một sản phẩm có thể làm:
Video 1:
Vấn đề khách hàng
Video 2:
Cách sử dụng
Video 3:
Demo sản phẩm
Video 4:
So sánh
Video 5:
Review
Video 6:
Câu hỏi thường gặp
Video 7:
Sai lầm khi chọn sản phẩmKhông cần chờ:
máy quay chuyên nghiệp.
Chỉ cần:
điện thoại + ánh sáng đủ tốt + sản phẩm thật + nội dung rõ ràng.
Bây giờ chúng ta bước vào phần thú vị nhất:
Đưa tiền thật vào hệ thống.
Nhưng đừng bắt đầu bằng ngân sách lớn.
Hãy coi chiến dịch đầu tiên là:
một thí nghiệm kinh doanh.
Ví dụ:
Sản phẩm:
Bình giữ nhiệt
Giá:
299.000đ
Ngân sách thử:
100.000đ/ngày
Thời gian:
Một khoảng thử nghiệm đủ để có dữ liệuMục tiêu ban đầu không phải:
"Phải kiếm 10 triệu."
Mục tiêu là:
Tìm hiểu xem thị trường phản ứng như thế nào.
Đừng chỉ ghi:
"Hôm nay bán được 3 đơn."
Hãy ghi toàn bộ phễu:
10.000
Lượt tiếp cận
↓
500
Lượt xem / click
↓
80
Tin nhắn
↓
20
Người quan tâm thực sự
↓
8
Đơn hàng
↓
7
Đơn hoàn tấtBây giờ bạn có dữ liệu để phân tích.
Ví dụ:
10.000 người thấy
↓
10 người click→ Có thể nội dung/Hook chưa đủ hấp dẫn.
Hoặc:
1.000 người click
↓
5 người mua→ Có thể vấn đề nằm ở trang bán hàng, giá, niềm tin hoặc sản phẩm.
Hoặc:
100 người mua
↓
30 người hoàn hàng→ Có thể vấn đề nằm ở chất lượng sản phẩm, kỳ vọng khách hàng hoặc quy trình giao hàng.
Đây là lý do:
Đừng chỉ nhìn kết quả cuối cùng.
Hãy tìm xem nút thắt nằm ở đâu.
Đây là bước biến một người bán hàng thành người kinh doanh.
Bạn phải biết:
Tiền đi đâu?
Một bảng đơn giản:
| Khoản mục | Số tiền |
|---|---|
| Doanh thu | 10.000.000đ |
| Giá vốn | -5.000.000đ |
| Vận chuyển | -800.000đ |
| Phí nền tảng | -500.000đ |
| Quảng cáo | -1.500.000đ |
| Chi phí khác | -500.000đ |
| Lợi nhuận | 1.700.000đ |
Doanh thu:
10 triệu
nghe rất lớn.
Nhưng thứ bạn thực sự cần quan tâm là:
1,7 triệu lợi nhuận.
Có người nói:
"Tôi bán được 100 triệu mỗi tháng!"
Nghe rất ấn tượng.
Nhưng nếu:
Doanh thu 100M
Chi phí 98M
-------------------
Lợi nhuận 2Mthì mô hình đó chưa chắc tốt.
Một người khác:
Doanh thu 40M
Chi phí 25M
-------------------
Lợi nhuận 15Mcó thể đang có một mô hình khỏe hơn.
Vì vậy:
Đừng chạy theo doanh thu nếu không biết lợi nhuận.
Mỗi ngày hoặc mỗi tuần, hãy theo dõi:
👥 Khách mới
🛒 Số đơn
💰 Doanh thu
📦 Giá vốn
📢 Chi phí quảng cáo
💬 Số tin nhắn
🔄 Số khách mua lại
💎 AOV
🎯 CAC
📈 Lợi nhuậnChỉ cần một file Excel hoặc Google Sheets đơn giản cũng đủ để bắt đầu.
AOV =
Doanh thu / Số đơnCAC =
Chi phí thu hút khách / Số khách mớiConversion Rate =
Số người mua / Số người tiếp cậnROAS =
Doanh thu từ quảng cáo / Chi phí quảng cáoNhưng hãy nhớ:
ROAS cao không tự động có nghĩa là lợi nhuận cao.
Đây là lúc bắt đầu chơi "trò chơi" thật sự.
Giả sử:
100 khách
↓
5 đơnMục tiêu đầu tiên có thể là:
100 khách
↓
7 đơnKhông cần tăng traffic.
Chỉ cần:
tăng tỷ lệ chuyển đổi.
Có 5 khu vực lớn.
Hook tốt hơn.
Video rõ hơn.
Demo thực tế hơn.
Ảnh tốt hơn
↓
Thông tin rõ hơn
↓
Đánh giá tốt hơn
↓
CTA rõ hơnCó thể thử:
299Kso với:
279Khoặc:
299K
+
quà tặngKhông phải lúc nào giảm giá cũng tốt.
Từ:
AOV = 250Ktăng lên:
AOV = 320Kbằng:
Combo.
Upsell.
Cross-sell.
Sản phẩm bổ sung.
Một khách:
Mua 1 lầncó thể trở thành:
Mua
↓
Mua lại
↓
Mua thêm
↓
Giới thiệuĐây chính là sức mạnh của Phần 3.
Đây là một tư duy rất quan trọng.
Giả sử:
100 khách
×
10% chuyển đổi
=
10 đơnNếu tối ưu tỷ lệ chuyển đổi:
100 khách
×
15%
=
15 đơnBạn tăng:
50% số đơn
mà không cần tăng số khách.
Giả sử:
10 đơn
×
300K
=
3M doanh thuTăng AOV:
10 đơn
×
400K
=
4MVẫn:
10 khách mua.
Nhưng doanh thu tăng thêm:
1 triệu đồng.
Khách hàng mua một lần:
100 khách
×
300K
=
30MNếu một phần khách mua lại:
100 khách
×
300K
×
2 lần
=
60MĐây là lý do:
LTV cực kỳ quan trọng.
Đây là mục tiêu cuối cùng của toàn bộ khóa học.
Không phải:
"Một ngày tôi bán được 20 đơn."
Mà là:
"Tôi có một hệ thống tạo ra đơn hàng đều đặn."
🛍️ SẢN PHẨM
↓
👥 KHÁCH HÀNG
↓
┌───────────┼───────────┐
↓ ↓ ↓
📱 Social 🔎 Google 💰 Ads
↓ ↓ ↓
└───────────┼───────────┘
↓
🌐 WEBSITE
↓
💬 TƯ VẤN
↓
🛒 ĐƠN HÀNG
↓
📦 GIAO HÀNG
↓
😊 HÀI LÒNG
↓
┌─────────┼─────────┐
↓ ↓ ↓
🔄 MUA LẠI ➕ MUA THÊM 📣 GIỚI THIỆU
↓ ↓ ↓
└─────────┼─────────┘
↓
💰 DOANH THU
↓
📊 DỮ LIỆU
↓
🤖 AI PHÂN TÍCH
↓
⚙️ TỐI ƯU
↓
🚀 TĂNG TRƯỞNGĐây không còn là một vài bài đăng bán hàng.
Đây là:
một hệ thống kinh doanh.
Một hệ thống tốt sẽ tự tạo ra dữ liệu cho lần tối ưu tiếp theo:
BÁN HÀNG
↓
CÓ DỮ LIỆU
↓
PHÂN TÍCH
↓
TÌM VẤN ĐỀ
↓
TEST
↓
KẾT QUẢ TỐT HƠN
↓
BÁN NHIỀU HƠN
↓
CÓ THÊM DỮ LIỆU
↓
PHÂN TÍCH
↓
...Đó là:
Growth Loop — vòng lặp tăng trưởng.
Hệ thống hoàn chỉnh có thể sử dụng AI ở nhiều vị trí:
Nghiên cứu
↓
🤖 AI
Nội dung
↓
🤖 AI
Video
↓
🤖 AI
Quảng cáo
↓
📊 Dữ liệu
↓
🤖 AI phân tích
Khách hàng
↓
🤖 AI hỗ trợ
Công việc lặp lại
↓
⚙️ AutomationNhưng vẫn có một trung tâm:
CON NGƯỜI RA QUYẾT ĐỊNH.
Giai đoạn đầu:
Bạn
↓
Tự tìm khách
↓
Tự viết bài
↓
Tự bán
↓
Tự đóng gói
↓
Tự chăm sócGiai đoạn sau:
BẠN
↓
XÂY QUY TRÌNH
↓
AI + CÔNG CỤ + TỰ ĐỘNG HÓA
↓
HỆ THỐNG
↓
KHÁCH HÀNG
↓
ĐƠN HÀNGBạn không còn chỉ là:
người làm việc trong cửa hàng.
Bạn trở thành:
người thiết kế hệ thống bán hàng.
Hãy kiểm tra:
☐ Có một sản phẩm thật
☐ Biết giá vốn
☐ Biết giá bán
☐ Biết lợi nhuận trên đơn
☐ Xác định khách hàng mục tiêu
☐ Có kênh bán hàng
☐ Có nội dung
☐ Có cách nhận đơn
☐ Có cách giao hàng
☐ Có cách chăm sóc khách
☐ Có theo dõi doanh thu
☐ Có theo dõi chi phí
☐ Biết CAC
☐ Biết AOV
☐ Theo dõi mua lại
☐ Biết quảng cáo nào có lời
☐ Biết quảng cáo nào cần cắt
☐ Có quy trình test
☐ Có quy trình tối ưu
☐ Có thể sử dụng AI để giảm việc lặp lạiNếu chưa đủ 20 mục cũng không sao.
Quan trọng là:
Bắt đầu xây từng mục một.
Bây giờ hãy thực sự chọn một sản phẩm.
Không chọn 10 sản phẩm.
Không mở 5 cửa hàng.
Không chạy 20 chiến dịch.
Chọn:
1 sản phẩm
↓
1 nhóm khách hàng
↓
1 kênh chính
↓
1 chiến dịch
↓
1 bảng theo dõiSau đó chạy thử.
Chuẩn bị sản phẩm.
Làm nội dung.
Đưa sản phẩm lên kênh bán hàng.
Bắt đầu kéo khách.
Theo dõi:
👥 Khách
💬 Tin nhắn
🛒 Đơn
💰 Doanh thu
📢 Chi phí
📈 Lợi nhuậnSau đó hỏi:
"Điểm nào đang làm mất tiền?"
và:
"Điểm nào đang tạo tiền?"
Bạn không cần:
❌ website triệu đô
❌ hàng nghìn sản phẩm
❌ hàng trăm nghìn follower
❌ văn phòng lớn
❌ đội ngũ khổng lồ
để bắt đầu.
Bạn cần:
Một sản phẩm có người muốn mua.
Sau đó:
TÌM KHÁCH
↓
BÁN
↓
ĐO
↓
HỌC
↓
TỐI ƯU
↓
BÁN TỐT HƠN
↓
TÁI ĐẦU TƯ
↓
MỞ RỘNGĐó là toàn bộ tư duy của khóa học.
Ban đầu:
Tôi cần một đơn hàng.
Sau đó:
Tôi cần nhiều đơn hàng.
Tiếp theo:
Tôi cần khách mua lại.
Sau nữa:
Tôi cần lợi nhuận.
Và cuối cùng:
Tôi cần một hệ thống có thể tạo ra lợi nhuận một cách ổn định.
Đây là sự thay đổi lớn nhất:
NGƯỜI BÁN HÀNG
↓
CÓ ĐƠN
↓
NGƯỜI KINH DOANH
↓
CÓ LỢI NHUẬN
↓
NGƯỜI XÂY HỆ THỐNG
↓
HỆ THỐNG TẠO RA ĐƠN
↓
HỆ THỐNG TẠO RA LỢI NHUẬN🛒 PHẦN 1
KIẾM ĐƠN KHÔNG CẦN QUẢNG CÁO
↓
💰 PHẦN 2
DÙNG TIỀN ĐỂ MUA KHÁCH
↓
🔥 PHẦN 3
KIẾM NHIỀU HƠN TỪ MỘT KHÁCH
↓
🌐 PHẦN 4
GOOGLE & WEBSITE
↓
🤖 PHẦN 5
AI GIÚP BÁN HÀNG
↓
🏆 PHẦN 6
LÀM RA TIỀN THẬTVà toàn bộ khóa học có thể cô đọng thành một câu:
Tìm đúng sản phẩm → tìm đúng khách → đưa sản phẩm đến trước khách → biến khách thành người mua → biến người mua thành khách hàng lâu dài → đo lợi nhuận → tối ưu → tự động hóa → rồi mới mở rộng.
Đừng học mãi về cách kiếm tiền.
Hãy chọn một sản phẩm và bắt đầu thử bán.
Đó là lúc kiến thức biến thành tiền thật.
quay về MỤC LỤC&
Cập nhật: 2026-09-13T07:33:00.248+07:00
Trong Bài 36 — Mail, chúng ta đã học cách Laravel gửi email bằng:
Mail
↓
Mailable
↓
Blade
↓
SMTP
↓
Người nhậnNhưng trong một ứng dụng thực tế, thông báo không chỉ có Email.
Người dùng có thể cần nhận thông báo qua:
🗃️ Database
📱 SMS
💬 Slack
🔔 Các kênh thông báo khác
Đây chính là lúc Notification của Laravel trở nên hữu ích.
Notification giúp chúng ta xây dựng một thông báo theo cách thống nhất, sau đó quyết định thông báo đó sẽ được gửi qua kênh nào.
Trong bài này chúng ta sẽ tìm hiểu:
1. Notification là gì?
2. Notification khác Mail thế nào?
3. Tạo Notification
4. notify()
5. Notifiable
6. Mail Notification
7. Database Notification
8. Đọc Notification
9. Đánh dấu đã đọc
10. Xóa Notification
11. Markdown Notification
12. Notification + Queue
13. Notification + Event
14. Notification theo nhiều kênh
15. Notification trong Blog CMS
16. Bài tập thực hành
17. Tổng kếtNotification là cơ chế của Laravel dùng để gửi thông báo đến một đối tượng nhận thông báo.
Ví dụ:
User đăng ký
↓
Notification
↓
"Chào mừng bạn!"Hoặc:
Admin xuất bản bài viết
↓
Notification
↓
"Có bài viết mới"Hoặc:
Đơn hàng hoàn tất
↓
Notification
↓
"Đơn hàng #1001 đã được giao"Notification đặc biệt hữu ích vì một thông báo có thể được gửi qua nhiều channel.
Ví dụ:
Notification
│
┌────────────┼────────────┐
▼ ▼ ▼
Email Database SlackĐây là điểm rất dễ nhầm.
Mail tập trung vào việc tạo và gửi email.
Mailable
↓
EmailVí dụ:
Mail::to($user->email)
->send(new WelcomeUser($user));Notification tập trung vào thông báo.
Notification
↓
Channel
↓
Email / Database / Slack / ...Ví dụ:
$user->notify(
new NewPostNotification($post)
);Notification có thể chọn nhiều channel:
return ['mail', 'database'];Vì vậy có thể hiểu đơn giản:
Mailable = xây dựng một email.
Notification = xây dựng một thông báo có thể đi qua nhiều kênh.
Notification thường được gửi tới một đối tượng có trait:
NotifiableThông thường chúng ta sẽ thấy trait này trong model&User.
Ví dụ:
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
use Notifiable;
}Sau đó User có thể nhận Notification:
$user->notify(
new NewPostNotification($post)
);Luồng:
User
│
└── Notifiable
│
▼
NotificationLaravel cung cấp Artisan để tạo Notification:
php artisan make:notification NewPostNotificationLaravel tạo:
app/
└── Notifications/
└── NewPostNotification.phpMột Notification thường có cấu trúc:
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class NewPostNotification extends Notification
{
use Queueable;
public function __construct()
{
//
}
public function via(object $notifiable): array
{
return ['mail'];
}
}Phần quan trọng nhất là:
via()Nó quyết định Notification được gửi qua channel nào.
via() — chọn ChannelVí dụ chỉ gửi Email:
public function via(object $notifiable): array
{
return ['mail'];
}Gửi Database:
public function via(object $notifiable): array
{
return ['database'];
}Gửi cả hai:
public function via(object $notifiable): array
{
return [
'mail',
'database',
];
}Mô hình:
Notification
│
┌─────┴─────┐
▼ ▼
Mail DatabaseĐây là một trong những ưu điểm lớn của Notification.
Laravel lưu trong bảng notifications. Bạn cần chạy migration:
php artisan notifications:table
php artisan migrate
Chỉnh trong class NewPostNotification
use App\Models\Post;
class NewPostNotification extends Notification
{
use Queueable;
public $post;
/**
* Create a new notification instance.
*/
public function __construct(Post $post)
{
$this->post = $post;
}
/**
* Get the notification's delivery channels.
*
* @return array
*/
public function via(object $notifiable): array
{
return [
'mail',
'database',
];
}
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Có bài viết mới')
->greeting('Xin chào!')
->line('Một bài viết mới vừa được tạo: ' . $this->post->title)
->action(
'Xem bài viết',
route('blog.show', $this->post->slug)
)
->line('Cảm ơn bạn đã theo dõi!');
}
public function toDatabase($notifiable)
{
return [
'post_id' => $this->post->id,
'title' => $this->post->title,
'slug' => $this->post->slug,
];
}
...
}
Sau mục 7, kiểm tra trong DB xem có bản ghi mới chưa().
Ví dụ chúng ta muốn thông báo:
Có bài viết mới được xuất bản.
Notification:
public function via(object $notifiable): array
{
return ['mail'];
}Sau đó tạo:
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Có bài viết mới')
->greeting('Xin chào!')
->line('Một bài viết mới vừa được xuất bản.')
->action(
'Xem bài viết',
url('/blog')
)
->line('Cảm ơn bạn đã theo dõi!');
}Import:
use Illuminate\Notifications\Messages\MailMessage;Laravel sẽ xây dựng email từ Notification.
Ghi chú: nếu có lỗi chỗ này, Laravel 13 sẽ gửi mẫu mail default của họ.
Sau khi có Notification vào PostController
use App\Models\User;
use Illuminate\Support\Facades\Auth;
public function store(Request $request)
{
$post = Post::create([
...
]);
$post->tags()->sync($request->tags ?? []);
if ($post->status === 'published') {
event(new PostPublished($post));
}
// Lấy user hiện tại từ Auth sau khi login
$user = Auth::user();
$user->notify(new NewPostNotification($post));
return redirect()
->route('admin.posts.show', $post)
->with('success', 'Bài viết đã được tạo thành công.');
}
Hoặc:
$user->notify(
new NewPostNotification($post)
);Laravel sẽ gọi:
$user
↓
notify()
↓
Notification
↓
via()
↓
mail
↓
EmailMột tính năng rất hay của Laravel Notification là lưu thông báo vào Database.
Ví dụ:
🔔 Thông báo
Có bài viết mới.
5 phút trướcNotification:
public function via(object $notifiable): array
{
return [
'database',
];
}Sau đó định nghĩa:
public function toDatabase(
object $notifiable
): array {
return [
'title' => 'Có bài viết mới',
'message' => 'Một bài viết vừa được xuất bản.',
];
}Laravel sẽ lưu dữ liệu Notification vào database.
Laravel cung cấp Artisan command:
php artisan make:notifications-tableSau đó:
php artisan migrateLaravel tạo bảng:
notificationsBảng này dùng để lưu các Database Notifications.
Một Notification có thể được liên kết với User thông qua hệ thống polymorphic của Laravel.
Mô hình:
users
│
│
▼
notificationsUser có thể truy cập các Notification của mình:
$user->notificationsVí dụ:
foreach ($user->notifications as $notification) {
echo $notification->data['title'];
}Dữ liệu trong data chính là dữ liệu mà chúng ta trả về từ:
toDatabase()Ví dụ:
return [
'title' => 'Có bài viết mới',
'message' => 'Laravel 13 đã ra mắt.',
];Laravel cũng hỗ trợ phân biệt:
Unread
ReadCó thể lấy Notification chưa đọc:
$user->unreadNotificationsVí dụ:
$count = $user
->unreadNotifications
->count();Sau đó hiển thị trên Navbar:
🔔 5Khi người dùng mở Notification:
$notification->markAsRead();Ví dụ:
foreach ($user->unreadNotifications as $notification) {
$notification->markAsRead();
}Có thể đánh dấu tất cả:
$user->unreadNotifications
->markAsRead();Mô hình:
NEW
│
▼
Unread
│
│ User mở
▼
ReadNotification không nhất thiết phải tồn tại mãi mãi.
Có thể xóa:
$notification->delete();Hoặc:
$user->notifications()->delete();Ví dụ trong trang:
🔔 Notifications
Có bài viết mới
Đơn hàng đã giao
Tài khoản đã được cập nhật
[ Xóa tất cả ]Laravel cũng hỗ trợ Notification sử dụng Markdown.
Ví dụ:
return (new MailMessage)
->subject('Bài viết mới')
->greeting('Xin chào!')
->line('Một bài viết mới vừa được xuất bản.')
->action(
'Xem bài viết',
url('/blog')
)
->line('Cảm ơn bạn!');Laravel sẽ tạo email dựa trên các thành phần Markdown Mail Notification.
Điều này giúp chúng ta không phải tự xây dựng toàn bộ HTML email.
Đây mới là sức mạnh thực sự.
Ví dụ:
public function via(object $notifiable): array
{
return [
'mail',
'database',
];
}Khi:
$user->notify(
new NewPostNotification($post)
);Laravel thực hiện:
NewPostNotification
│
▼
via()
│
┌────────┴────────┐
▼ ▼
Mail Database
│ │
▼ ▼
Email NotificationNgười dùng vừa nhận email:
📧 Có bài viết mớivừa có Notification trong website:
🔔 Có bài viết mớiĐây là phần rất quan trọng vì chúng ta vừa học Queue ở Bài 35.
Nếu Notification gửi Email:
User Action
↓
Notification
↓
Email
↓
SMTPRequest có thể phải chờ.
Thay vào đó, Notification có thể được đưa vào Queue.
Một Notification có thể implement:
ShouldQueueVí dụ:
use Illuminate\Contracts\Queue\ShouldQueue;
class NewPostNotification
extends Notification
implements ShouldQueue
{
use Queueable;
}Khi đó:
User
↓
Notification
↓
Queue
↓
Response ngay
↓
Queue Worker
↓
Email / Database / ...Đây là mô hình rất phù hợp với hệ thống production.
Chúng ta đã học:
Bài 33
Observer
Bài 34
Event & Listener
Bài 35
Queue
Bài 36
MailBây giờ Bài 37 kết nối tất cả:
User Action
│
▼
Event
│
▼
Listener
│
▼
Notification
│
┌──────┴──────┐
▼ ▼
Email Database
│
▼
QueueVí dụ:
Admin publish Post
↓
PostPublished
↓
SendNewPostNotification
↓
NewPostNotification
↓
Queue
↓
SubscribersĐây là kiến trúc rất đẹp cho Blog CMS.
Hãy áp dụng vào dự án Blog CMS chúng ta đang xây dựng.
Giả sử có:
User
Post
CategoryMột User có thể đăng ký nhận thông báo.
Khi Admin xuất bản Post:
Admin
│
▼
Publish Post
│
▼
Event
│
▼
Listener
│
▼
Notification
│
├───────────────┐
▼ ▼
Email DatabaseDatabase Notification:
🔔 Thông báo
Bài viết mới:
Laravel 13 — Notification
[Xem bài viết]01 ứng dụng thực tế thường hiển thị số Notification chưa đọc trong navigation.blade.php sau tên user.
Ví dụ Blade:
<x-slot name="trigger">
<button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150">
<div>{{ Auth::user()->name }}</div>
<!-- Phần Notifications -->
<div class="hidden sm:flex sm:items-center sm:ms-6"
x-data="{ count: {{ Auth::user()->unreadNotifications->count() }} }"
x-init="
window.addEventListener('notification-read', e => {
if(count > 0) count--;
});
window.addEventListener('notification-read-all', e => {
count = 0;
});
">
<a href="{{ route('notifications.index') }}" class="relative inline-flex items-center px-3 py-2 text-gray-500 hover:text-gray-700">
<!-- Icon chuông -->
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002
6.002 0 00-4-5.659V4a2 2 0 10-4 0v1.341C7.67
6.165 6 8.388 6 11v3.159c0 .538-.214
1.055-.595 1.436L4 17h5m6 0v1a3 3 0
11-6 0v-1m6 0H9" />
</svg>
<!-- Badge số lượng -->
<template x-if="count > 0">
<span class="absolute top-0 right-0 inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none text-white bg-red-600 rounded-full"
x-text="count"></span>
</template>
</a>
</div>
<!-- END Phần Notifications -->
Kết quả:
┌───────────────┐
│ 🔔 3 │
└───────────────┘Người dùng click:
🔔
↓
Notifications
↓
Mark as Readroutes/web.php// bài 37 - Notifications
use App\Http\Controllers\NotificationController;
Route::prefix('notifications')->name('notifications.')->middleware('auth')->group(function () {
// 1. Khai báo các route TĨNH trước
Route::get('/', [NotificationController::class, 'index'])->name('index');
Route::post('/read-all', [NotificationController::class, 'markAllAsRead'])->name('readAll');
Route::delete('/delete-all', [NotificationController::class, 'deleteAll'])->name('deleteAll');
// 2. Khai báo các route CÓ THAM SỐ {id} sau
Route::post('/{id}/read', [NotificationController::class, 'markAsRead'])->name('read');
Route::delete('/{id}', [NotificationController::class, 'destroy'])->name('destroy');
});
php artisan make:controller NotificationController
Trong file app/Http/Controllers/NotificationController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class NotificationController extends Controller
{
public function index()
{
$notifications = auth()->user()->notifications;
return view('notifications.index', compact('notifications'));
}
public function markAsRead($id)
{
$notification = auth()->user()->notifications()->find($id);
if ($notification) {
$notification->markAsRead();
}
return redirect()->route('notifications.index');
}
public function markAllAsRead()
{
// Đánh dấu tất cả thông báo chưa đọc của user hiện tại thành đã đọc
auth()->user()->unreadNotifications->markAsRead();
return response()->json(['success' => true]);
}
public function deleteAll()
{
// Xóa toàn bộ thông báo (cả đã đọc lẫn chưa đọc) của user hiện tại
auth()->user()->notifications()->delete();
return response()->json(['success' => true]);
}
public function destroy($id)
{
$notification = auth()->user()->notifications()->findOrFail($id);
$notification->delete();
return response()->json(['success' => true]);
}
}
resources/views/notifications/index.blade.php<x-app-layout>
<div class="max-w-7xl mx-auto py-8"
x-data="{
unreadCount: {{ auth()->user()->unreadNotifications->count() }},
allRead: false,
allDeleted: false
}">
<x-ui.card>
<!-- THANH TIÊU ĐỀ & CÁC NÚT THAO TÁC HÀNG LOẠT -->
<div class="flex items-center justify-between mb-4 border-b pb-3">
<h1 class="text-2xl font-bold flex items-center">
Thông báo
<template x-if="unreadCount > 0">
<span class="ml-2 inline-flex items-center px-2 py-1 text-xs font-bold leading-none text-white bg-red-600 rounded-full"
x-text="unreadCount"></span>
</template>
</h1>
<div class="flex items-center space-x-4">
<!-- Nút Đánh dấu tất cả đã đọc -->
<button
x-show="unreadCount > 0"
@click.prevent="
fetch('{{ route('notifications.readAll') }}', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
}
}).then(res => res.json())
.then(data => {
if(data.success){
unreadCount = 0;
allRead = true;
}
})
"
class="text-sm text-blue-600 hover:text-blue-800 font-medium hover:underline">
Đánh dấu tất cả là đã đọc
</button>
<!-- Nút Xóa tất cả thông báo -->
<button
@click.prevent="
if (confirm('Bạn có chắc chắn muốn xóa TẤT CẢ thông báo?')) {
fetch('{{ route('notifications.deleteAll') }}', {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
}
}).then(res => res.json())
.then(data => {
if(data.success){
unreadCount = 0;
allDeleted = true;
}
})
}
"
class="text-sm text-red-600 hover:text-red-800 font-medium hover:underline">
Xóa tất cả
</button>
</div>
</div>
<!-- DANH SÁCH THÔNG BÁO -->
<template x-if="!allDeleted">
<div>
@forelse($notifications as $notification)
<div class="border-b py-3 flex items-center justify-between"
x-data="{
read: {{ $notification->read_at ? 'true' : 'false' }},
deleted: false
}"
x-show="!deleted"
x-transition>
<div>
<strong>{{ $notification->data['title'] }}</strong>
<p class="text-gray-600 text-sm">{{ $notification->data['slug'] }}</p>
</div>
<div class="flex items-center space-x-3">
<!-- Trạng thái Đã đọc / Chưa đọc lẻ -->
<template x-if="!read && !allRead">
<button
@click.prevent="
fetch('{{ route('notifications.read', $notification->id) }}', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
}
}).then(res => res.json())
.then(data => {
if(data.success){
read = true;
if(unreadCount > 0) unreadCount--;
}
})
"
class="text-blue-600 hover:underline text-sm">
Đánh dấu đã đọc
</button>
</template>
<template x-if="read || allRead">
<span class="text-gray-400 text-sm">Đã đọc</span>
</template>
<!-- Nút Xóa từng thông báo -->
<button
@click.prevent="
if (confirm('Bạn có chắc muốn xóa thông báo này?')) {
fetch('{{ route('notifications.destroy', $notification->id) }}', {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
}
}).then(res => res.json())
.then(data => {
if(data.success){
if(!read && !allRead && unreadCount > 0) {
unreadCount--;
}
deleted = true;
}
})
}
"
class="text-red-600 hover:underline text-sm">
Xóa
</button>
</div>
</div>
@empty
<p class="text-gray-500 py-4">Không có thông báo nào.</p>
@endforelse
</div>
</template>
<!-- THÔNG BÁO KHI ĐÃ XÓA SẠCH SẼ -->
<template x-if="allDeleted">
<p class="text-gray-500 py-4">Không có thông báo nào.</p>
</template>
</x-ui.card>
</div>
</x-app-layout>
Không nên nhồi quá nhiều dữ liệu vào Notification.
Ví dụ tốt:
return [
'title' => 'Bài viết mới',
'message' => 'Laravel 13 — Notification',
'post_id' => $post->id,
];Sau đó có thể dùng:
$post = Post::find(
$notification->data['post_id']
);Hoặc tốt hơn, xây dựng URL từ ID:
url('/posts/' . $notification->data['post_id'])Mục tiêu là Notification chỉ chứa những thông tin cần thiết.
Hai khái niệm này khác nhau.
Mô tả:
Một chuyện gì đó đã xảy ra.
Ví dụ:
PostPublishedMô tả:
Thông báo cho ai đó về chuyện đó.
Ví dụ:
NewPostNotificationCó thể hình dung:
EVENT
"Post đã được publish"
│
▼
LISTENER
"Thông báo cho subscribers"
│
▼
NOTIFICATION
"Có bài viết mới"Có thể dùng quy tắc đơn giản:
Email phức tạp
Hóa đơn
Báo giá
PDF
Email marketing
Email template riêngThông báo hệ thống
Thông báo bài viết
Thông báo đơn hàng
Thông báo tài khoản
Unread notification
Nhiều channelVí dụ:
Invoice
↓
MailableTrong khi:
"Đơn hàng của bạn đã được giao"
↓
Notification
├── Email
└── DatabaseKhi viết Test, chúng ta không muốn gửi Notification thật.
Laravel cung cấp:
Notification::fake();Sau đó:
$user->notify(
new NewPostNotification($post)
);Kiểm tra:
Notification::assertSentTo(
$user,
NewPostNotification::class
);Mô hình:
Test
↓
Notification::fake()
↓
Action
↓
Notification
↓
AssertKhông có email thật được gửi đi.
Bây giờ hãy xây dựng Notification cho Blog CMS.
php artisan make:notification NewPostNotificationpublic function __construct(
public Post $post
) {}public function via(object $notifiable): array
{
return [
'mail',
'database',
];
}public function toMail(
object $notifiable
): MailMessage {
return (new MailMessage)
->subject('Có bài viết mới')
->greeting('Xin chào!')
->line(
'Một bài viết mới vừa được xuất bản.'
)
->action(
'Xem bài viết',
url('/posts/' . $this->post->id)
);
}public function toDatabase(
object $notifiable
): array {
return [
'title' => 'Có bài viết mới',
'message' => $this->post->title,
'post_id' => $this->post->id,
];
}$user->notify(
new NewPostNotification($post)
);Sau khi Notification hoạt động ổn định, chuyển sang:
implements ShouldQueueSau đó chạy:
php artisan queue:workKhi đó hệ thống sẽ có:
Publish Post
↓
Notification
↓
Queue
↓
Worker
├── Email
└── DatabaseSau 37 bài, dự án của chúng ta bắt đầu có kiến trúc khá hoàn chỉnh:
BLOG CMS
│
┌──────────────┼──────────────┐
│ │ │
▼ ▼ ▼
Request Event Model
│ │ │
▼ ▼ ▼
Controller Listener Eloquent
│ │
│ ▼
│ Notification
│ │
│ ┌──────┴──────┐
│ ▼ ▼
│ Mail Database
│ │
│ ▼
│ Queue
│ │
└───────┴──────────────► UserĐây không còn đơn thuần là:
Route
↓
Controller
↓
Viewmà đã bắt đầu trở thành một ứng dụng có kiến trúc thực tế.
| Thành phần | Vai trò |
|---|---|
Notification | Đại diện cho một thông báo |
Notifiable | Cho phép đối tượng nhận Notification |
notify() | Gửi Notification |
via() | Chọn channel |
toMail() | Nội dung Mail Notification |
toDatabase() | Dữ liệu Database Notification |
notifications | Các Notification của User |
unreadNotifications | Notification chưa đọc |
markAsRead() | Đánh dấu đã đọc |
ShouldQueue | Đưa Notification vào Queue |
Notification::fake() | Fake Notification khi Test |
Notification giải quyết một bài toán rất phổ biến:
Làm thế nào để thông báo cho người dùng mà không phải tự xây dựng một hệ thống riêng cho từng loại thông báo?
Laravel cho phép chúng ta xây dựng:
🔔 Notification
│
┌────────────┼────────────┐
▼ ▼ ▼
📧 Mail 🗃️ Database 💬 Channel khác
│ │
▼ ▼
Email Notification
│
▼
UserVà khi kết hợp với Queue:
Event
↓
Listener
↓
Notification
↓
Queue
↓
Worker
↓
Mail / DatabaseĐây là một trong những mô hình rất đáng học vì nó kết nối trực tiếp những kiến thức chúng ta đã học từ Bài 33 → Bài 37:
33 — Observer
↓
34 — Event & Listener
↓
35 — Queue
↓
36 — Mail
↓
37 — NotificationChúng ta sẽ chuyển từ bài toán:
"Làm sao thông báo cho User?"
sang:
"Làm sao để Laravel không phải truy vấn Database liên tục?"
và bắt đầu tìm hiểu:
Cache
Redis
File Cache
Database Cache
remember()
forget()
flush()
Cache::put()
Cache::get()Đây sẽ là bước đầu tiên để tối ưu hiệu năng Laravel 13.
x1
quay về MỤC LỤC
Cập nhật: 2026-09-13T16:00:45.324+07:00
Sau 4 phần, chúng ta đã xây được một quy trình:
TÌM SẢN PHẨM
↓
TÌM KHÁCH
↓
LÀM NỘI DUNG
↓
QUẢNG CÁO
↓
BÁN HÀNG
↓
KHÁCH MUA LẠI
↓
GOOGLE + WEBSITENhưng khi bắt đầu làm thật, bạn sẽ gặp một vấn đề:
Có quá nhiều việc phải làm.
Mỗi ngày có thể phải:
Nghĩ ý tưởng.
Viết bài.
Viết tiêu đề.
Làm hình.
Làm video.
Trả lời khách.
Phân tích quảng cáo.
Theo dõi đơn.
Chăm sóc khách cũ.
Nếu một người tự làm tất cả, rất dễ rơi vào tình trạng:
Sáng: Nghĩ nội dung
Trưa: Làm video
Chiều: Trả lời khách
Tối: Phân tích quảng cáo
Đêm: Chuẩn bị bài ngày maiVà đây là lúc AI trở thành một trợ lý cực kỳ hữu ích.
Nhưng cần hiểu đúng:
AI không phải cỗ máy tự động kiếm tiền.
AI là công cụ giúp bạn:
làm nhanh hơn, thử nhiều hơn và phân tích tốt hơn.
Một trong những công việc khó nhất khi bán hàng là:
Bán cái gì?
Nếu chọn sai sản phẩm, tất cả những bước phía sau đều khó.
AI có thể giúp bạn nghiên cứu ban đầu.
Ví dụ bạn muốn bán đồ cho:
🏠 gia đình
Thay vì ngồi suy nghĩ:
Bán gì?
Bán gì?
Bán gì?Bạn có thể yêu cầu AI phân tích:
Khách hàng:
Gia đình trẻ
Hãy liệt kê:
- những vấn đề thường gặp
- sản phẩm có thể giải quyết
- sản phẩm dễ làm nội dung
- sản phẩm có khả năng mua lại
- sản phẩm có thể bán kèmAI có thể biến một thị trường lớn thành:
THỊ TRƯỜNG
↓
VẤN ĐỀ
↓
NHU CẦU
↓
SẢN PHẨM
↓
Ý TƯỞNG KINH DOANHĐây là điều phải nhớ.
AI có thể đưa ra:
giả thuyết.
Nhưng thị trường mới đưa ra:
câu trả lời.
Ví dụ AI nói:
"Bình giữ nhiệt là sản phẩm tiềm năng."
Điều đó không có nghĩa bạn nhập 1.000 chiếc về sẽ bán hết.
Phải kiểm tra:
AI
↓
Ý tưởng
↓
Kiểm tra thị trường
↓
Test nội dung
↓
Có người hỏi?
↓
Có người mua?
↓
CÓ
↓
MỚI TĂNG QUY MÔKhách là ai?
Họ đang gặp vấn đề gì?
Họ có sẵn sàng trả tiền không?
Có mua lại không?
Có thể bán thêm gì?
Bạn muốn bán:
🎧 Tai nghe Bluetooth.
AI có thể giúp bạn xây bản đồ:
Tai nghe
│
├── Người đi tập
│ ├── Nhẹ
│ ├── Không rơi
│ └── Chống mồ hôi
│
├── Người đi làm
│ ├── Gọi điện
│ ├── Chống ồn
│ └── Pin lâu
│
└── Game thủ
├── Độ trễ thấp
├── Micro
└── Âm thanhTừ một sản phẩm, bạn đã có nhiều nhóm khách hàng.
Và mỗi nhóm lại có:
một lý do mua khác nhau.
Đây có lẽ là ứng dụng dễ thấy nhất.
Bạn có một sản phẩm.
Thay vì mỗi ngày phải tự nghĩ:
"Hôm nay đăng gì?"
AI có thể giúp tạo ra:
1 sản phẩm
↓
10 vấn đề
↓
10 ý tưởng video
↓
10 tiêu đề
↓
10 bài viết
↓
10 kịch bảnMột sản phẩm có thể trở thành:
hàng chục hoặc hàng trăm nội dung khác nhau.
Ví dụ:
☕ Máy pha cà phê mini.
AI có thể giúp bạn tạo:
"Tại sao cà phê ở nhà thường không ngon bằng quán?"
"3 bước pha cà phê nhanh trước khi đi làm."
"Máy pha cà phê mini khác gì máy thông thường?"
"7 giờ sáng, bạn không muốn xếp hàng mua cà phê..."
"Dùng máy pha cà phê mini 30 ngày, đây là điều tôi nhận ra."
Bạn có thể sử dụng AI cho toàn bộ dây chuyền:
Ý tưởng
↓
Tiêu đề
↓
Hook
↓
Kịch bản
↓
Caption
↓
CTA
↓
Lịch đăngVí dụ:
Sản phẩm:
Bình giữ nhiệt
↓
50 ý tưởng
↓
10 ý tưởng tốt nhất
↓
5 kịch bản
↓
3 video thử nghiệm
↓
Video hiệu quả nhất
↓
Làm thêm biến thểĐây là cách sử dụng AI thông minh hơn:
AI tạo số lượng → con người chọn chất lượng.
Video bán hàng thường cần gây chú ý rất nhanh.
AI có thể tạo nhiều kiểu Hook:
❓ "Bạn có đang mắc lỗi này không?"
😱 "Đừng mua sản phẩm này trước khi biết điều này."
💡 "Có một cách đơn giản để..."
⚠️ "90% người dùng đang làm sai..."
🎯 "Nếu bạn đang gặp vấn đề này, hãy xem..."Nhưng đừng biến mọi video thành:
"90% người dùng..."
"Bạn sẽ không tin..."
"Đừng bỏ qua..."
Nếu dùng quá nhiều, nội dung sẽ trở nên:
giật tít và thiếu tin cậy.
Hook tốt phải liên quan trực tiếp đến nội dung thật.
Đây là bước tiếp theo.
Trước đây:
Ý tưởng
↓
Viết kịch bản
↓
Quay
↓
Cắt
↓
Lồng tiếng
↓
Phụ đề
↓
ĐăngCó thể mất hàng giờ.
AI có thể hỗ trợ một số công đoạn:
Ý tưởng
↓
Kịch bản AI
↓
Hình ảnh / video AI
↓
Giọng đọc AI
↓
Phụ đề tự động
↓
Biên tập
↓
ĐăngĐây là một lỗi rất phổ biến.
AI có thể tạo:
Người mẫu hoàn hảo
+
Căn phòng hoàn hảo
+
Ánh sáng hoàn hảo
+
Giọng nói hoàn hảoNhưng khách hàng có thể cảm thấy:
"Đây là quảng cáo AI."
Trong nhiều trường hợp, video quay bằng điện thoại thật lại đáng tin hơn.
Ví dụ:
📱 Tay thật
📦 Sản phẩm thật
🏠 Không gian thật
👤 Người bán thậtSau đó dùng AI để:
Viết kịch bản.
Tạo phụ đề.
Xóa tạp âm.
Cắt đoạn thừa.
Tạo nhiều phiên bản.
Viết caption.
Đây thường là cách thực tế hơn.
Hãy chia công việc:
AI
├── Tìm ý tưởng
├── Viết nháp
├── Phân tích
├── Tạo biến thể
└── Tự động hóa
CON NGƯỜI
├── Chọn sản phẩm
├── Kiểm tra sự thật
├── Quay sản phẩm
├── Kiểm tra chất lượng
├── Nói chuyện với khách
└── Quyết định cuối cùngAI mạnh ở:
tốc độ + số lượng + xử lý thông tin.
Con người mạnh ở:
phán đoán + trải nghiệm + niềm tin + quyết định.
Đây là một ứng dụng rất mạnh.
Ở Phần 2, chúng ta đã có các dữ liệu:
Chi phí
Lượt hiển thị
Click
Tin nhắn
Đơn hàng
Doanh thuVí dụ:
Quảng cáo A
Chi phí: 500K
Đơn: 2
Doanh thu: 600K
Quảng cáo B
Chi phí: 500K
Đơn: 8
Doanh thu: 2.400K
Quảng cáo C
Chi phí: 500K
Đơn: 5
Doanh thu: 1.500KThay vì chỉ nhìn bảng số liệu, bạn có thể đưa dữ liệu cho AI và yêu cầu:
"Hãy tìm quảng cáo có hiệu quả tốt nhất, chỉ ra điểm bất thường và đề xuất các giả thuyết để test tiếp."
AI có thể giúp bạn:
DỮ LIỆU
↓
AI PHÂN TÍCH
↓
PHÁT HIỆN MẪU
↓
ĐẶT GIẢ THUYẾT
↓
TEST
↓
DỮ LIỆU MỚIVí dụ:
CTR
CPC
CPM
Conversion Rate
CAC
AOV
ROASNhưng phải hiểu:
AI tính toán được không có nghĩa AI hiểu toàn bộ doanh nghiệp của bạn.
Ví dụ ROAS cao nhưng lợi nhuận thấp.
Tại sao?
ROAS cao
↓
Doanh thu tốt
↓
Nhưng...
↓
Giá vốn cao
+
Phí cao
+
Khuyến mãi lớn
↓
LỢI NHUẬN THẤPVì vậy:
Đừng tối ưu doanh thu nếu mục tiêu thực sự là lợi nhuận.
Đây là cấp độ cao nhất của phần này.
Hãy nhìn một ngày bán hàng:
Khách nhắn tin
↓
Trả lời
↓
Gửi thông tin
↓
Khách đặt hàng
↓
Ghi đơn
↓
Xác nhận
↓
Theo dõi
↓
Chăm sócMột số bước có thể tự động hóa.
Ví dụ:
KHÁCH ĐIỀN FORM
↓
HỆ THỐNG NHẬN DỮ LIỆU
↓
LƯU KHÁCH HÀNG
↓
GỬI THÔNG BÁO
↓
TẠO ĐƠN
↓
GỬI XÁC NHẬNCon người chỉ cần xử lý những trường hợp đặc biệt.
Ví dụ khách hỏi:
"Sản phẩm này còn hàng không?"
AI có thể trả lời dựa trên dữ liệu được cung cấp.
Khách hỏi:
"Bao lâu giao tới?"
Hệ thống có thể đưa ra thông tin phù hợp.
Khách hỏi:
"Có màu đen không?"
AI kiểm tra dữ liệu sản phẩm rồi trả lời.
Nhưng với những trường hợp:
Khiếu nại
Hoàn tiền
Tranh chấp
Khách VIP
Vấn đề phức tạpnên chuyển cho:
con người.
Đừng xây:
AI
↓
Làm tất cảHãy xây:
CON NGƯỜI
│
┌────────┴────────┐
↓ ↓
AI HỆ THỐNG
↓ ↓
Tạo / phân tích Tự động hóa
│ │
└────────┬────────┘
↓
KẾT QUẢAI là:
trợ lý.
Không phải:
ông chủ.
Hãy tưởng tượng một người bán hàng có:
👤 Bạn
│
├── 🤖 AI nghiên cứu
│
├── ✍️ AI viết nội dung
│
├── 🎬 AI hỗ trợ video
│
├── 📊 AI phân tích dữ liệu
│
├── 💬 Chatbot hỗ trợ khách
│
└── ⚙️ Automation xử lý việc lặp lạiBạn vẫn chỉ có một người.
Nhưng năng lực sản xuất có thể lớn hơn rất nhiều so với việc làm tất cả bằng tay.
Đây chính là:
Leverage — đòn bẩy.
AI có thể phân tích.
Bạn phải quyết định.
AI có thể đề xuất.
Bạn phải kiểm soát.
AI có thể viết.
Bạn phải kiểm tra.
AI có thể hỗ trợ.
Con người nên xử lý khi cần.
AI có thể đưa ra nhiều góc nhìn.
Nhưng:
người chịu trách nhiệm cuối cùng vẫn là bạn.
Đừng hỏi AI:
"Bán hàng thế nào?"
Hãy đưa cho AI bối cảnh + dữ liệu + mục tiêu.
Ví dụ:
SẢN PHẨM:
Bình giữ nhiệt 500ml
KHÁCH HÀNG:
Nhân viên văn phòng 25–35 tuổi
GIÁ:
299.000đ
LỢI THẾ:
Giữ nhiệt lâu, nhỏ gọn
MỤC TIÊU:
Tạo video TikTok bán hàng
HÃY TẠO:
- 10 Hook
- 5 ý tưởng video
- 3 kịch bản
- CTA cho từng videoKết quả thường tốt hơn rất nhiều so với:
"Viết cho tôi một bài quảng cáo."
Nếu kết hợp toàn bộ những gì đã học:
🤖 AI
│
┌───────────┼───────────┐
↓ ↓ ↓
NGHIÊN CỨU NỘI DUNG PHÂN TÍCH
│ │ │
└───────────┼───────────┘
↓
MARKETING
↓
┌────────┴────────┐
↓ ↓
TỰ NHIÊN QUẢNG CÁO
↓ ↓
└────────┬────────┘
↓
WEBSITE
↓
ĐƠN HÀNG
↓
KHÁCH HÀNG
↓
MUA LẠI
↓
LTV ↑Đây mới là cách nhìn AI trong kinh doanh:
Không phải "AI làm thay tôi".
Mà là:
"AI giúp hệ thống bán hàng của tôi mạnh hơn."
Chọn một sản phẩm thật.
Sau đó dùng AI thực hiện 5 nhiệm vụ:
Tạo:
30 ý tưởng nội dung.
Chọn 5 ý tưởng tốt nhất và viết:
5 kịch bản video.
Tạo:
3 phiên bản Hook cho mỗi video.
Sau khi chạy quảng cáo, đưa dữ liệu vào AI và yêu cầu:
Phân tích quảng cáo nào tốt/xấu và đưa ra giả thuyết test tiếp theo.
Liệt kê tất cả công việc lặp lại trong quy trình bán hàng và đánh dấu:
🟢 Có thể tự động hóa
🟡 AI có thể hỗ trợ
🔴 Cần con ngườiBây giờ toàn bộ hệ thống đã tiến thêm một bước:
PHẦN 1
📱 Kiếm khách tự nhiên
↓
PHẦN 2
💰 Dùng tiền mua khách
↓
PHẦN 3
🔥 Kiếm nhiều hơn từ khách cũ
↓
PHẦN 4
🌐 Google + Website
↓
PHẦN 5
🤖 AI + AutomationNhưng AI không phải điểm kết thúc.
Điểm quan trọng nhất vẫn là:
Làm thật.
Chọn một sản phẩm.
Đưa lên một kênh.
Tạo nội dung.
Tìm khách.
Chạy thử quảng cáo.
Nhận đơn.
Ghi lại doanh thu.
Ghi lại chi phí.
Tính lợi nhuận.
Sau đó:
tối ưu.
Và đó chính là bước chuyển từ học bán hàng sang làm kinh doanh thật.
28. 🧪 Chọn một sản phẩm thật
29. 📱 Dựng kênh bán hàng thật
30. 💰 Chạy chiến dịch thật
31. 📊 Đo tiền vào — tiền ra
32. 🚀 Tối ưu để có lợi nhuận
33. 🏆 Xây hệ thống bán hàng có thể chạy mỗi ngày
quay về MỤC LỤC
Cập nhật: 2026-09-12T07:24:00.113+07:00
Ở Phần 1, chúng ta tìm khách bằng nội dung.
Ở Phần 2, chúng ta dùng tiền để mua khách bằng quảng cáo.
Ở Phần 3, chúng ta học cách kiếm nhiều tiền hơn từ khách hàng cũ.
Nhưng còn một nguồn khách cực kỳ đặc biệt:
Những người đang chủ động đi tìm thứ họ muốn mua.
Ví dụ một người mở Google và gõ:
"mua máy in gần đây"hoặc:
"bàn phím cơ giá rẻ"hoặc:
"cửa hàng sửa laptop quận 1"Đây là những khách hàng có ý định mua rất rõ ràng.
Thay vì cố làm cho họ nhìn thấy quảng cáo, chúng ta có thể xây dựng hệ thống để:
Khi khách tìm → sản phẩm của chúng ta xuất hiện.
Google không chỉ là nơi tìm thông tin.
Google cũng là:
một cái chợ khổng lồ.
Mỗi ngày có rất nhiều người tìm kiếm:
sản phẩm
dịch vụ
giá cả
địa điểm
đánh giá
so sánhVí dụ:
"giày chạy bộ nam"
"mua nồi cơm điện"
"máy lọc nước giá bao nhiêu"
"shop bán phụ kiện điện thoại"Người tìm kiếm đã có một vấn đề.
Nhiệm vụ của chúng ta là:
KHÁCH TÌM
↓
GOOGLE
↓
THẤY BẠN
↓
CLICK
↓
XEM SẢN PHẨM
↓
MUAĐây là điểm rất quan trọng.
Trên Facebook hoặc TikTok:
Bạn tìm khách.
Trên Google:
Khách tìm bạn.
Ví dụ:
Bạn làm video:
"5 lỗi khiến điện thoại nhanh hết pin."
Người dùng đang xem video giải trí.
Bạn phải tạo sự chú ý.
Người dùng gõ:
"thay pin iPhone gần đây"
Họ đã có nhu cầu.
Họ đang tìm nơi mua hoặc sử dụng dịch vụ.
Vì vậy có thể hình dung:
Facebook / TikTok
KHÁCH
↑
│
BẠN ĐI TÌM KHÁCH
Google
BẠN
↑
│
KHÁCH ĐI TÌM BẠNĐây là lý do Google Search rất có giá trị đối với hoạt động bán hàng.
Đưa khách từ Google về website mới chỉ là một nửa công việc.
Nếu khách click vào:
https://example.commà nhìn thấy:
Trang chủ
Tin tức
Sản phẩm
Danh mục
Banner
Menu
...nhưng không biết phải làm gì tiếp theo, khách có thể rời đi.
Website bán hàng cần trả lời thật nhanh:
Bạn bán gì?
Giá bao nhiêu?
Tại sao tôi nên mua?
Mua như thế nào?
Có thể thiết kế:
┌──────────────────────────────┐
│ HÌNH SẢN PHẨM │
│ │
├──────────────────────────────┤
│ TÊN SẢN PHẨM │
│ │
│ ⭐⭐⭐⭐⭐ │
│ │
│ 💰 299.000đ │
│ │
│ ✓ Lợi ích 1 │
│ ✓ Lợi ích 2 │
│ ✓ Lợi ích 3 │
│ │
│ [ 🛒 MUA NGAY ] │
│ [ 💬 NHẮN TIN ] │
│ │
└──────────────────────────────┘Không cần website quá phức tạp.
Một trang bán hàng tốt thường quan trọng hơn một website có hàng trăm tính năng nhưng khiến khách khó mua.
Một trang được thiết kế chủ yếu để thuyết phục khách thực hiện một hành động thường được gọi là:
Landing Page
Ví dụ:
GOOGLE
↓
Landing Page
↓
Thông tin sản phẩm
↓
Lợi ích
↓
Đánh giá
↓
Giá
↓
Ưu đãi
↓
MUA NGAYCó thể bắt đầu bằng 7 phần:
Nói ngay sản phẩm giải quyết vấn đề gì.
Cho khách thấy sản phẩm.
Tại sao khách nên quan tâm?
Đánh giá, hình ảnh khách hàng, trải nghiệm thực tế...
Nói rõ giá.
Nếu có.
Một hành động rõ ràng:
Mua ngay
Đặt hàng
Nhắn tin
Gọi ngay
Đây là điều không được xem nhẹ.
Rất nhiều khách hàng đến từ:
TikTok
Facebook
Googlevà phần lớn có thể truy cập bằng điện thoại.
Nếu website:
❌ chữ quá nhỏ
❌ nút khó bấm
❌ ảnh quá lớn
❌ tải quá chậm
❌ form quá dàithì bạn có thể mất khách ngay cả khi đã đưa họ đến website.
Hãy kiểm tra:
📱 Mobile
💻 Desktopvà đặc biệt:
Khách có thể hiểu sản phẩm trong vài giây đầu tiên hay không?
Nếu bạn có cửa hàng vật lý, Google còn mạnh hơn nữa.
Ví dụ:
"Cửa hàng laptop gần tôi"
"tiệm sửa điện thoại gần đây"
"quán ăn gần đây"
"mua hoa gần tôi"Người tìm kiếm không chỉ muốn:
biết thông tin.
Họ có thể muốn:
đi đến cửa hàng ngay.
Hãy tưởng tượng một người đang đứng cách cửa hàng của bạn 2 km.
Họ mở Google và tìm:
"sửa laptop"
Nếu thông tin doanh nghiệp của bạn xuất hiện:
🏪 Tên cửa hàng
⭐ 4.8
📍 1.2 km
🕐 Đang mở cửa
📞 Gọi
🗺️ Chỉ đường
🌐 Websitethì Google đã trở thành:
người dẫn khách đến cửa hàng.
Một doanh nghiệp địa phương nên chú ý đến những thông tin cơ bản:
Tên
Địa chỉ
Số điện thoại
Giờ mở cửa
Website
Danh mục
Hình ảnh
Sản phẩm / dịch vụ
Đánh giáThông tin càng rõ ràng, khách càng dễ quyết định.
Đặc biệt:
Đừng để khách phải đi tìm số điện thoại hoặc địa chỉ.
Hãy tưởng tượng hai cửa hàng:
Cửa hàng A
⭐⭐⭐⭐⭐
350 đánh giá
Cửa hàng B
⭐⭐⭐
12 đánh giáNếu những yếu tố khác tương đương, khách thường có xu hướng tin cửa hàng có nhiều bằng chứng xã hội hơn.
Nhưng:
Đừng mua đánh giá giả.
Hãy xây dựng trải nghiệm đủ tốt để khách thật sự muốn đánh giá.
Sau khi khách mua hàng, có thể gửi một lời nhắc đơn giản:
"Nếu anh/chị hài lòng với sản phẩm, rất mong anh/chị dành một phút để đánh giá cửa hàng."
Bây giờ chúng ta ghép toàn bộ hệ thống lại.
Một khách hàng có thể đi qua hành trình:
🔎 TÌM KIẾM
"máy lọc nước tốt"
↓
🌐 GOOGLE
↓
🏪 WEBSITE / TRANG SẢN PHẨM
↓
👀 XEM THÔNG TIN
↓
⭐ XEM ĐÁNH GIÁ
↓
💰 XEM GIÁ
↓
🛒 ĐẶT HÀNG
↓
💵 DOANH THUĐây là một phễu bán hàng từ Google.
Có hai con đường chính để xuất hiện trên Google:
GOOGLE
│
┌───────┴───────┐
↓ ↓
QUẢNG CÁO TỰ NHIÊN
↓ ↓
Google Ads SEOBạn trả tiền để quảng cáo có thể xuất hiện cho những tìm kiếm phù hợp.
Bạn xây dựng nội dung và website để có cơ hội xuất hiện trong kết quả tìm kiếm tự nhiên.
Quảng cáo có thể đưa khách đến nhanh hơn.
SEO thường cần thời gian hơn.
Nhưng nếu làm tốt, website có thể tiếp tục tạo ra lượng truy cập mà không phải trả tiền cho từng lượt click tự nhiên.
Giả sử bạn bán:
Máy lọc nước gia đình
Thay vì chỉ có một trang:
/products/may-loc-nuocBạn có thể xây dựng thêm nội dung:
/may-loc-nuoc-gia-dinh
/may-loc-nuoc-cho-can-ho
/may-loc-nuoc-gia-bao-nhieu
/cach-chon-may-loc-nuoc
/may-loc-nuoc-nao-tot
/so-sanh-may-loc-nuocMỗi nội dung có thể trả lời một nhu cầu tìm kiếm khác nhau.
WEBSITE
│
┌────────┼────────┐
↓ ↓ ↓
Sản phẩm Hướng dẫn So sánh
│ │ │
└────────┼────────┘
↓
GOOGLE
↓
KHÁCHMột sai lầm là chỉ nghĩ:
"Tôi phải SEO từ khóa bán hàng."
Không nhất thiết.
Khách có thể trải qua nhiều giai đoạn:
CHƯA BIẾT
↓
CÓ VẤN ĐỀ
↓
TÌM HIỂU
↓
SO SÁNH
↓
TÌM SẢN PHẨM
↓
MUAVí dụ:
"Tại sao nước có mùi?"
↓
"Cách lọc nước tại nhà"
↓
"Máy lọc nước RO là gì?"
↓
"So sánh máy lọc nước A B"
↓
"Máy lọc nước A giá bao nhiêu?"
↓
"Mua máy lọc nước A"Nếu website xuất hiện ở nhiều giai đoạn, bạn có cơ hội xây dựng niềm tin trước khi khách mua.
Đây là lúc những phần trước bắt đầu kết nối với nhau.
NỘI DUNG
/ | \
↓ ↓ ↓
Facebook TikTok SEO
│ │ │
└───────┼───────┘
↓
WEBSITE
↓
KHÁCH HÀNG
↓
ĐƠN HÀNG
↓
KHÁCH CŨ
↓
MUA LẠIBạn không còn xây từng kênh riêng biệt.
Bạn đang xây:
một hệ thống kéo khách.
Hãy chọn một sản phẩm thật.
Sau đó thử tìm trên Google:
"[tên sản phẩm]"
"mua [tên sản phẩm]"
"[tên sản phẩm] giá"
"[tên sản phẩm] tốt"
"[tên sản phẩm] gần tôi"Ghi lại:
🔎 Khách đang tìm gì?
🏪 Đối thủ nào đang xuất hiện?
🌐 Họ dẫn khách đến đâu?
💰 Giá bán thế nào?
⭐ Có bao nhiêu đánh giá?
📱 Website của họ có dễ mua không?Sau đó tự hỏi:
"Nếu khách tìm sản phẩm này hôm nay, họ có tìm thấy mình không?"
Nếu câu trả lời là không, bạn đã tìm thấy một cơ hội kinh doanh.
Facebook và TikTok giống như những nơi bạn đi thuê mặt bằng.
Google giống như một con đường cực kỳ đông người.
Còn website là:
🏠 ngôi nhà của chính bạn trên Internet.
Bạn có thể xây:
WEBSITE
│
┌───────────────┼───────────────┐
↓ ↓ ↓
SẢN PHẨM NỘI DUNG KHÁCH HÀNG
│ │ │
↓ ↓ ↓
BÁN HÀNG SEO DỮ LIỆUVà khác với một bài đăng trên mạng xã hội, website cho phép bạn kiểm soát nhiều hơn:
Nội dung.
Sản phẩm.
Giá.
Form đặt hàng.
Dữ liệu khách hàng.
Hành trình mua hàng.
SEO.
Thương hiệu.
Đừng nghĩ:
"Tôi cần làm một website."
Hãy nghĩ:
"Tôi cần một nơi để biến người đang có nhu cầu thành khách hàng."
Website không phải để cho đẹp.
Website phải làm được:
GOOGLE
↓
KHÁCH
↓
HIỂU
↓
TIN
↓
MUA
↓
ĐỂ LẠI THÔNG TIN
↓
MUA LẠIĐó mới là website bán hàng.
Đến đây, chúng ta đã có 4 mảnh ghép:
🛒 PHẦN 1
KIẾM ĐƠN KHÔNG CẦN QUẢNG CÁO
↓
📱 Nội dung + mạng xã hội
💰 PHẦN 2
DÙNG TIỀN ĐỂ MUA KHÁCH
↓
🎯 Quảng cáo
🔥 PHẦN 3
KIẾM NHIỀU HƠN TỪ MỘT KHÁCH
↓
🔄 Mua lại + bán thêm + giới thiệu
🌐 PHẦN 4
GOOGLE & WEBSITE
↓
🔎 Khách chủ động tìm bạnNhưng có một vấn đề mới:
Làm tất cả những việc này bằng tay sẽ rất mất thời gian.
Viết bài.
Nghĩ ý tưởng.
Viết quảng cáo.
Làm video.
Phân tích dữ liệu.
Trả lời khách.
Theo dõi đơn hàng.
Lặp đi lặp lại mỗi ngày.
Và đây chính là lúc một công cụ mới xuất hiện:
23. 🤖 Dùng AI tìm ý tưởng sản phẩm
24. ✍️ Dùng AI sản xuất nội dung
25. 🎬 Dùng AI làm video
26. 📊 Dùng AI phân tích quảng cáo
27. ⚙️ Tự động hóa những việc lặp lại
quay về MỤC LỤC
Cập nhật: 2026-09-11T07:58:37.419+07:00
Ở Phần 1, chúng ta học cách kiếm khách mà không cần quảng cáo.
Ở Phần 2, chúng ta học cách dùng tiền để mua thêm khách.
Nhưng có một vấn đề lớn:
Tìm được một khách hàng mới thường tốn tiền và công sức.
Nếu khách mua một lần rồi biến mất, bạn phải quay lại từ đầu:
Tìm khách
↓
Quảng cáo
↓
Tư vấn
↓
Chốt đơn
↓
Tìm khách mới
↓
Quảng cáo
↓
Tư vấn
↓
Chốt đơn
↓
...Đây là một mô hình rất mệt.
Một người bán hàng giỏi sẽ đặt câu hỏi khác:
"Khách đã mua rồi, làm sao để họ quay lại?"
Và cao hơn nữa:
"Làm sao để một khách hàng tạo ra nhiều doanh thu hơn trong suốt thời gian họ mua hàng của mình?"
Đó chính là tư duy của Customer Lifetime Value — giá trị vòng đời khách hàng.
Đây là cách đơn giản nhất để kiếm thêm tiền từ khách hàng hiện tại.
Ví dụ bạn bán:
🧴 Dầu gội
Khách mua hôm nay.
Sau một thời gian, họ sẽ dùng hết.
Thay vì tiếp tục tìm một khách hoàn toàn mới, bạn có thể nhắc khách:
"Chị dùng gần hết dầu gội chưa ạ? Bên em đang có đợt hàng mới."
Đây là bán lại — repeat purchase.
Khách mới:
Chưa biết bạn
↓
Chưa tin bạn
↓
Phải thuyết phục
↓
Mới muaKhách cũ:
Đã biết sản phẩm
↓
Đã biết người bán
↓
Đã từng mua
↓
Nếu hài lòng
↓
Có khả năng mua lạiBạn không phải bắt đầu từ số 0.
Hãy tìm hiểu:
Khách thường mua lại sau bao lâu?
Ví dụ:
| Sản phẩm | Chu kỳ có thể gặp |
|---|---|
| Dầu gội | 30–60 ngày |
| Mỹ phẩm | 30–90 ngày |
| Thực phẩm | 7–30 ngày |
| Vật tư tiêu hao | 15–60 ngày |
| Phụ kiện | Không cố định |
Đây chỉ là ví dụ để tư duy; sản phẩm thực tế phải dựa trên dữ liệu khách hàng của bạn.
Nếu khách thường mua lại sau 30 ngày:
Ngày 0
↓
Mua hàng
Ngày 20
↓
Nhắc nhẹ
Ngày 30
↓
Ưu đãi / đề nghị mua lại
Ngày 40
↓
Chăm sóc tiếpKhông cần spam.
Mục tiêu là:
Xuất hiện đúng lúc khách có khả năng cần sản phẩm.
Khách đã quyết định mua một sản phẩm.
Đây là thời điểm rất tốt để giới thiệu:
sản phẩm bổ sung.
Ví dụ:
Khách mua laptop
↓
Có thể cần:
├── Chuột
├── Bàn phím
├── Túi
└── Đế laptopĐây gọi là cross-sell — bán chéo.
Ví dụ:
Gói cơ bản
299K
↓
Gói nâng cao
399K
↓
Gói Premium
599KThay vì chỉ hỏi:
"Anh/chị có mua không?"
Bạn có thể đưa ra lựa chọn:
"Anh/chị muốn bản tiêu chuẩn hay bản đầy đủ?"
Khách đã có ý định mua sẽ dễ cân nhắc hơn khi các lựa chọn được trình bày rõ ràng.
Giả sử:
100 khách
×
300.000đ
=
30.000.000đNếu tăng giá trị trung bình mỗi đơn lên:
100 khách
×
400.000đ
=
40.000.000đBạn vẫn có:
100 khách
nhưng doanh thu tăng thêm:
10.000.000đ
Đây là lý do không phải lúc nào muốn tăng doanh thu cũng phải tìm thêm khách.
Một khách hàng hài lòng có thể trở thành:
một nhân viên bán hàng miễn phí.
Ví dụ bạn bán sản phẩm cho anh A.
Anh A dùng thấy tốt.
Anh A giới thiệu cho:
A
├── B
├── C
└── DNếu B, C, D tiếp tục hài lòng:
A
├── B ──┬── E
│ └── F
├── C
└── D ─── GKhách hàng bắt đầu tạo ra khách hàng mới.
Bạn có thể thiết kế đơn giản:
Giới thiệu bạn bè
↓
Bạn mới mua hàng
↓
Người giới thiệu nhận ưu đãi
↓
Khách mới nhận ưu đãiVí dụ:
"Giới thiệu một người bạn mua hàng, cả hai cùng nhận ưu đãi."
Điểm quan trọng là:
Ưu đãi phải nhỏ hơn giá trị kinh tế mà khách mới mang lại.
Nếu một khách mới tạo ra lợi nhuận 200K, bạn không thể tặng 300K chỉ để lấy một khách.
Hãy tạo lý do để khách muốn chia sẻ.
Ví dụ:
"Nếu bạn của anh/chị cũng đang tìm sản phẩm này, gửi họ mã giới thiệu này để cả hai được ưu đãi."
Khách không cảm thấy mình đang "làm việc bán hàng cho bạn".
Họ chỉ đơn giản:
chia sẻ một thứ họ thấy hữu ích.
Đây là một trong những đòn bẩy mạnh nhất của bán hàng.
Có 3 cách cơ bản:
Bán sản phẩm phiên bản cao hơn.
299K
↓
399K
↓
599KBán thêm sản phẩm liên quan.
Điện thoại
+
Ốp
+
Kính
+
SạcGộp nhiều sản phẩm thành một gói.
Mua riêng:
A = 199K
B = 149K
C = 99K
Tổng = 447KTạo combo:
🔥 COMBO A + B + C
399KKhách cảm thấy:
"Mua combo có lợi hơn."
Người bán:
tăng giá trị đơn hàng.
AOV — Average Order Value
Tức là:
Giá trị đơn hàng trung bình.
Công thức:
AOV = Tổng doanh thu / Số đơn hàngVí dụ:
Doanh thu = 50.000.000đ
Số đơn = 200
AOV = 50.000.000 / 200
= 250.000đNếu bạn nâng AOV lên:
300.000đvới cùng 200 đơn:
200 × 300.000
= 60.000.000đBạn tăng:
10 triệu doanh thu
mà không cần tăng số đơn.
Đây là bước cực kỳ quan trọng.
Nếu khách hàng chỉ tồn tại trên:
Facebook
TikTok
Sàn thương mại điện tửthì bạn đang phụ thuộc vào nền tảng.
Một ngày thuật toán thay đổi:
Lượt tiếp cận ↓
Doanh thu ↓Hoặc quảng cáo đắt lên:
CAC ↑
Lợi nhuận ↓Bạn cần xây một tài sản riêng:
Danh sách khách hàng.
Ở mức cơ bản:
Tên
Số điện thoại / kênh liên hệ
Sản phẩm đã mua
Ngày mua
Giá trị đơn
Lần mua gần nhấtGhi chú:
Mình tạo sẵn file Excel có đủ 06 cột này + thêm tính năng chống trùng số điện thoại&+ lọc theo cột
Sau này có thể phân nhóm:
KH mới
↓
KH đã mua
↓
KH mua lại
↓
KH trung thành
↓
KH giá trị caoVí dụ:
100 khách hàng
↓
┌─────┼─────┐
↓ ↓ ↓
Mới Cũ VIPCần:
làm quen + tạo niềm tin.
Cần:
nhắc mua lại + sản phẩm liên quan.
Cần:
ưu tiên + chăm sóc đặc biệt.
Nếu gửi cùng một thông điệp cho tất cả:
hiệu quả sẽ thấp.
Người mới thường nghĩ:
Khách
↓
Mua 300K
↓
HếtNgười kinh doanh bắt đầu nghĩ:
Khách
↓
Mua 300K
↓
Mua lại 300K
↓
Mua thêm 200K
↓
Giới thiệu 2 người
↓
Mua tiếp 500K
↓
...Một khách hàng có thể tạo ra:
hàng triệu đồng doanh thu trong suốt vòng đời.
Đó mới là lý do tại sao khách hàng cũ rất có giá trị.
Một cách đơn giản để hình dung:
LTV ≈
Giá trị mỗi đơn
×
Số lần mua
×
Thời gian duy trìVí dụ:
Mỗi đơn: 300K
Mua trung bình: 5 lần
Doanh thu vòng đời:
300K × 5
= 1.500KNếu chi phí để có khách ban đầu là:
CAC = 100Kthì bức tranh kinh doanh hoàn toàn khác so với việc nhìn riêng đơn hàng đầu tiên.
Đây là điểm cực kỳ quan trọng.
Không được nghĩ:
"Khách này đem lại 1,5 triệu doanh thu → quá lời."
Phải tính:
Doanh thu
− Giá vốn
− Phí vận hành
− Phí nền tảng
− Chi phí giao hàng
− Khuyến mãi
− Chi phí chăm sóc
− Chi phí quảng cáo
=
LỢI NHUẬNMục tiêu cuối cùng không phải:
có thật nhiều khách.
Mà là:
có những khách hàng tạo ra lợi nhuận tốt.
Có thể nhớ bằng công thức:
KHÁCH HÀNG
│
┌───────────┼───────────┐
↓ ↓ ↓
MUA LẠI MUA THÊM GIỚI THIỆU
│ │ │
↓ ↓ ↓
Repeat Cross/ Referral
UpsellVà tất cả được xây trên:
NIỀM TIN
↓
SẢN PHẨM TỐT
↓
TRẢI NGHIỆM TỐT
↓
KHÁCH QUAY LẠIKhông có sản phẩm tốt và trải nghiệm tốt thì những kỹ thuật này rất khó bền vững.
Chọn một sản phẩm thật bạn đang bán hoặc dự định bán.
Viết ra:
🛒 SẢN PHẨM:
Giá: ....................
🔄 KHÁCH CÓ THỂ MUA LẠI:
Sau bao lâu? ............
➕ SẢN PHẨM BÁN THÊM:
1. ......................
2. ......................
3. ......................
⬆️ SẢN PHẨM CAO CẤP HƠN:
..........................
📦 COMBO:
..........................
📣 CHƯƠNG TRÌNH GIỚI THIỆU:
..........................
🧲 CÁCH LƯU KHÁCH HÀNG:
..........................Sau đó tính:
AOV = Doanh thu / Số đơn
CAC = Chi phí có khách / Số khách mới
LTV = Giá trị khách hàng tạo ra trong vòng đờiKhi bắt đầu theo dõi được CAC + AOV + LTV, bạn sẽ nhìn việc bán hàng rất khác.
Ba phần đầu tạo thành một hệ thống:
PHẦN 1
Không quảng cáo
↓
Tìm khách tự nhiên
↓
Có đơn
PHẦN 2
Dùng tiền
↓
Mua khách
↓
Có thêm đơn
PHẦN 3
Chăm sóc khách
↓
Mua lại
↓
Mua thêm
↓
Giới thiệu
↓
Tăng LTVVà lúc này chúng ta có một thay đổi rất lớn:
Không còn chỉ đi tìm khách hàng.
Chúng ta bắt đầu xây một tài sản khách hàng.
Nhưng vẫn còn một nguồn khách cực kỳ mạnh mà chúng ta chưa khai thác:
Người đang chủ động lên Google và tìm đúng sản phẩm mình bán.
Nếu có thể xuất hiện đúng lúc họ đang tìm kiếm, bạn có thể nhận được khách mà không nhất thiết phải trả tiền cho từng lượt xem quảng cáo.
Đó là lúc bước sang:
19. 🔎 Đưa sản phẩm lên Google
20. 🏪 Làm trang bán hàng có khả năng chốt đơn
21. 📍 Kéo khách quanh khu vực cửa hàng
22. 🛍️ Bán hàng từ Google Search
quay về MỤC LỤC
Cập nhật: 2026-09-16T21:45:32.722+07:00
Ở Phần 1, chúng ta học cách kiếm đơn mà không cần bỏ tiền mua quảng cáo.
Đó là giai đoạn rất quan trọng.
Bạn đăng video → có người xem → có người nhắn tin → tư vấn → chốt đơn.
Nhưng có một vấn đề:
Khách hàng tự nhiên không phải lúc nào cũng đến đủ nhanh.
Một video có thể đạt 100.000 lượt xem, nhưng video tiếp theo chỉ có 500 lượt. Một ngày có 20 người hỏi, hôm sau chẳng có ai.
Khi đã có một sản phẩm bán được, chúng ta có thể chuyển sang bước tiếp theo:
Dùng tiền để đưa sản phẩm đến trước mắt nhiều khách hàng hơn.
Đó chính là quảng cáo.
Sai lầm phổ biến nhất của người mới là:
"Có sản phẩm → chạy quảng cáo."
Không nên làm như vậy.
Quảng cáo không biến một sản phẩm tệ thành sản phẩm tốt.
Nó chỉ giúp sản phẩm được nhiều người nhìn thấy hơn.
Nếu sản phẩm chưa có người mua, hình ảnh chưa tốt, giá chưa hợp lý, nội dung chưa thuyết phục... thì chạy quảng cáo có thể khiến bạn mất tiền nhanh hơn.
Trước khi quảng cáo, hãy hỏi:
SẢN PHẨM
↓
Có người cần?
↓
Có người mua?
↓
Có lợi nhuận?
↓
Có thể làm nội dung?
↓
CÓ
↓
CHẠY QUẢNG CÁOVí dụ bạn bán:
Bình giữ nhiệt 299.000đ
Bạn đăng video tự nhiên và nhận được:
10.000 lượt xem
↓
80 tin nhắn
↓
20 đơn hàngĐây là một tín hiệu rất đáng chú ý.
Bạn đã biết rằng:
Có người quan tâm.
Có người hỏi.
Có người chịu mua.
Nội dung có khả năng tạo hành động.
Lúc này quảng cáo có ý nghĩa hơn nhiều.
Người mới thường mắc một lỗi:
"Muốn bán nhiều thì phải quảng cáo thật nhiều."
Không.
Ở giai đoạn đầu, mục tiêu không phải là kiếm thật nhiều tiền.
Mục tiêu là:
Mua dữ liệu với chi phí thấp.
Ví dụ:
Ngân sách:
100.000đ/ngày
↓
Quảng cáo
↓
Có bao nhiêu người xem?
Có bao nhiêu người click?
Có bao nhiêu người nhắn?
Có bao nhiêu người mua?
↓
ĐÁNH GIÁ100.000đ bỏ ra để thử nghiệm có thể giúp bạn biết quảng cáo có tiềm năng hay không.
Nếu tốt → tăng dần.
Nếu xấu → dừng.
Quảng cáo giống như 01 phòng thí nghiệm.
Bạn đưa vào:
💵 tiền
và nhận lại:
📊 dữ liệu.
Sau nhiều lần thử, bạn mới tìm ra công thức có lợi nhuận.
Đây là kỹ năng quan trọng nhất trong phần quảng cáo.
Đừng tạo một quảng cáo duy nhất rồi ngồi chờ.
Hãy tạo nhiều phiên bản.
Ví dụ cùng một sản phẩm:
"Đi làm cả ngày mà nước trong bình cứ nóng lên?"
"Bình giữ nhiệt này giữ nước lạnh nhiều giờ..."
"3 giờ chiều ở văn phòng, mở bình ra vẫn còn lạnh."
"Một chiếc bình giữ nhiệt chưa tới 300K..."
Cùng một sản phẩm nhưng:
SẢN PHẨM
│
┌───────┼────────┐
↓ ↓ ↓
Video A Video B Video C
↓ ↓ ↓
└───────┼────────┘
↓
SO SÁNH KẾT QUẢBạn không cần đoán.
Hãy để dữ liệu trả lời.
Có thể test từng yếu tố:
Hai video khác nhau.
Hai ảnh quảng cáo khác nhau.
Hai cách viết khác nhau.
Hai nhóm khách hàng khác nhau.
Ví dụ:
299.000đ
vs
299.000đ
+ miễn phí vận chuyểnHoặc:
Mua 1: 299K
Mua 2: 499KMỗi lần test nên có một giả thuyết rõ ràng.
Đây là lúc bạn phải ngừng nhìn vào:
❤️ lượt thích
👁️ lượt xem
💬 bình luận
và bắt đầu nhìn vào:
💰 TIỀN
Ví dụ:
| Quảng cáo | Chi phí | Đơn hàng | Doanh thu |
|---|---|---|---|
| A | 100K | 0 | 0 |
| B | 100K | 2 | 598K |
| C | 100K | 1 | 299K |
| D | 100K | 5 | 1.495K |
Nhìn qua có thể thấy quảng cáo D rất hấp dẫn.
Nhưng doanh thu chưa phải lợi nhuận.
Giả sử một sản phẩm bán:
Giá bán 300.000đ
Giá vốn 150.000đ
Phí vận chuyển 20.000đ
Phí nền tảng 15.000đ
Chi phí khác 15.000đ
--------------------------------
Còn lại trước QC 100.000đNếu bạn bỏ:
100.000đ quảng cáođể có một đơn hàng:
Lợi nhuận ≈ 0đNhư vậy quảng cáo không thực sự có lời.
CAC — Customer Acquisition Cost
Tức là:
Chi phí để có được một khách hàng.
Ví dụ:
Chi quảng cáo: 1.000.000đ
Có:
20 khách mua
CAC = 1.000.000 / 20
= 50.000đ/kháchNếu mỗi khách mang lại lợi nhuận 120.000đ trước chi phí quảng cáo:
120K - 50K
= 70K→ Có khả năng mở rộng.
Đây là kỹ năng mà người mới thường không muốn làm.
Họ nghĩ:
"Chắc thêm vài ngày nữa sẽ tốt."
Rồi:
100K
↓
100K
↓
100K
↓
100K
↓
100KTiền cứ thế biến mất.
Nó phải tạo ra kết quả kinh doanh.
Nếu một quảng cáo:
Chi tiền
↓
Có người xem
↓
Có click
↓
Có người hỏi
↓
Không ai muathì phải tìm nguyên nhân.
Có thể vấn đề nằm ở:
Video.
Sản phẩm.
Giá.
Trang bán hàng.
Cách tư vấn.
Ưu đãi.
Đối tượng khách hàng.
Không phải lúc nào quảng cáo cũng là thủ phạm.
QUẢNG CÁO
↓
Có dữ liệu?
/ \
CÓ CHƯA
↓ ↓
Đánh giá Chờ thêm
↓
Có khả năng lời?
/ \
CÓ KHÔNG
↓ ↓
GIỮ / TEST DỪNGĐiều quan trọng là:
Đừng yêu một quảng cáo.
Nếu nó không hiệu quả, hãy bỏ nó.
Khi tìm được quảng cáo tốt, bạn bắt đầu bước tiếp theo:
Scale — mở rộng.
Ví dụ:
100K/ngày
↓
Quảng cáo có lời
↓
200K/ngày
↓
300K/ngày
↓
500K/ngàyNhưng không nên tăng ngân sách một cách mù quáng.
Bởi vì:
Một quảng cáo có lời ở 100K/ngày chưa chắc có lời ở 10 triệu/ngày.
Khi ngân sách tăng, quảng cáo có thể tiếp cận những nhóm khách hàng kém phù hợp hơn.
Hãy nghĩ theo mô hình:
TIỀN
↓
QUẢNG CÁO
↓
KHÁCH
↓
ĐƠN HÀNG
↓
LỢI NHUẬN
↓
TÁI ĐẦU TƯ
↓
QUẢNG CÁO
↓
KHÁCH
↓
...Đây mới là vòng lặp kinh doanh.
Nếu vòng lặp này hoạt động:
1đ quảng cáo
↓
tạo ra
↓
> 1đ lợi nhuận có thể tái đầu tưthì bạn bắt đầu có một cỗ máy tăng trưởng.
Hãy chọn một sản phẩm thật.
Không cần sản phẩm hoàn hảo.
Ghi ra:
Tên sản phẩm:
Giá bán:
Giá vốn:
Lợi nhuận trước quảng cáo:
Quảng cáo A:
Quảng cáo B:
Quảng cáo C:Sau đó chuẩn bị 3 nội dung quảng cáo khác nhau.
Bắt đầu với ngân sách nhỏ.
Ghi lại:
💵 Chi phí quảng cáo
👁️ Lượt tiếp cận
👆 Lượt click
💬 Tin nhắn
🛒 Đơn hàng
💰 Doanh thu
📈 Lợi nhuậnSau một thời gian, bạn sẽ không còn nói:
"Tôi cảm giác quảng cáo này tốt."
Mà có thể nói:
"Quảng cáo B tạo ra khách hàng với chi phí 47.000đ, trong khi quảng cáo A là 93.000đ."
Đó chính là lúc bạn bắt đầu làm marketing bằng dữ liệu thay vì cảm tính.
Phần 1 dạy chúng ta:
Kiếm khách mà không cần trả tiền.
Phần 2 dạy:
Dùng tiền để mua thêm khách.
Nhưng một doanh nghiệp thật sự không thể chỉ kiếm một đơn hàng từ một khách hàng.
Nếu mỗi lần bán hàng bạn đều phải bỏ tiền tìm một khách mới, chi phí sẽ ngày càng cao.
Bước tiếp theo là câu hỏi quan trọng hơn:
Làm thế nào để một khách hàng mua nhiều lần và tạo ra nhiều tiền hơn theo thời gian?
Đó chính là PHẦN 3 — KIẾM NHIỀU HƠN TỪ MỘT KHÁCH.
quay về MỤC LỤC
Cập nhật: 2026-09-16T21:18:51.154+07:00
Trong các bài trước, chúng ta đã đi qua:
Observer
Event & Listener
Queue
Đến Bài 36, chúng ta bắt đầu một chức năng rất quan trọng trong các ứng dụng web thực tế:
📧 Gửi Email bằng Laravel
Email có thể được sử dụng cho:
✉️ Xác nhận đăng ký tài khoản
🔐 Đặt lại mật khẩu
📩 Thông báo khi có bài viết mới
🛒 Xác nhận đơn hàng
💳 Thông báo thanh toán
👤 Thông báo tài khoản
📢 Gửi thông báo hệ thống
📎 Gửi email kèm file
⚙️ Kết hợp Email + Queue để gửi email ở background
Laravel cung cấp một API gửi mail thống nhất dựa trên Symfony Mailer, hỗ trợ nhiều mail transport như SMTP, Mailgun, Postmark, Amazon SES và sendmail. (Laravel)
Trong bài này chúng ta sẽ xây dựng hệ thống email theo mô hình:
Laravel
│
├── Mailable
│
├── Blade Email
│
├── Mail Configuration
│
├── SMTP
│
├── Send Email
│
├── Attachment
│
├── Markdown Email
│
├── Preview Email
│
└── Queue EmailTrong Laravel, mỗi loại email thường được biểu diễn bởi một class gọi là Mailable.
Ví dụ:
app/
└── Mail/
├── WelcomeUser.php
├── OrderShipped.php
└── ResetPassword.phpMỗi Mailable chịu trách nhiệm mô tả một loại email.
Ví dụ:
WelcomeUser
↓
Email chào mừng
OrderShipped
↓
Email thông báo giao hàng
InvoicePaid
↓
Email thông báo thanh toánLaravel tạo Mailable bằng Artisan:
php artisan make:mail WelcomeUserSau khi chạy lệnh, Laravel tạo class trong:
app/Mail/WelcomeUser.phpĐây là cách chính thức Laravel sử dụng để tạo Mailable. (Laravel)
Thông tin mail của ứng dụng thường được cấu hình thông qua:
.envvà:
config/mail.phpVí dụ .env:
MAIL_MAILER=smtp
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USERNAME="your@gmail.com"
MAIL_PASSWORD="your_app_password"
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS="your@gmail.com"
MAIL_FROM_NAME="${APP_NAME}"Không nên viết trực tiếp username/password vào source code (file mail.php).
Chú ý:
password mail là App Password
(vào Gmail mục Xác minh 02 bước-Mật khẩu ứng dụng-Gõ tên 'Laravel'-xóa hết khoảng trắng)
php artisan config:clear
php artisan tinker --execute="Mail::raw('test', function($m){ $m->to('nguyenminhk2000@gmail.com')->subject('Test'); }); echo 'sent';"
Thay vào đó:
.env
↓
config/mail.php
↓
Laravel Mail
↓
SMTP ServerTạo email:
php artisan make:mail WelcomeUserLaravel sẽ tạo:
app/Mail/WelcomeUser.phpMột Mailable hiện đại có thể sử dụng:
envelope()
content()
attachments()Ví dụ:
<?php
namespace App\Mail;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
class WelcomeUser extends Mailable
{
public function envelope(): Envelope
{
return new Envelope(
subject: 'Chào mừng bạn đến với website',
);
}
public function content(): Content
{
return new Content(
view: 'emails.welcome',
);
}
}Trong đó:
envelope()dùng để cấu hình thông tin email.
content()dùng để xác định nội dung email.
Laravel hiện sử dụng Envelope và Content trong cấu trúc Mailable hiện đại. (Laravel)
Tạo file:
resources/views/emails/welcome.blade.phpVí dụ:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Chào mừng</title>
</head>
<body>
<h1>Chào mừng bạn!</h1>
<p>
Cảm ơn bạn đã đăng ký tài khoản.
</p>
</body>
</html>Laravel sử dụng Blade để render HTML email giống như các View thông thường. (Laravel)
Giả sử chúng ta muốn gửi tên người dùng.
Mailable:
<?php
namespace App\Mail;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
class WelcomeUser extends Mailable
{
public function __construct(
public string $name
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Chào mừng bạn!',
);
}
public function content(): Content
{
return new Content(
view: 'emails.welcome',
);
}
}Blade:
<h1>
Xin chào {{ $name }}
</h1>
<p>
Chào mừng bạn đến với website.
</p>Khi Laravel render View:
$name
↓
Blade
↓
HTML EmailLaravel cung cấp Mail facade.
use Illuminate\Support\Facades\Mail;
use App\Mail\WelcomeUser;Sau đó:
Mail::to($user->email)
->send(new WelcomeUser($user->name));Ví dụ trong RegisteredUserController
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Mail\WelcomeUser; // Import Mailable
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail; // Import Mail Facade
class RegisteredUserController extends Controller
{
public function store(Request $request)
{
// 1. Validate dữ liệu
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => ['required', 'confirmed'],
]);
// 2. Tạo User mới
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
// 3. GỬI MAIL WELCOME
Mail::to($user->email)->send(new WelcomeUser($user));
// 4. Cho đăng nhập hoặc chuyển hướng
auth()->login($user);
return redirect('/dashboard')->with('status', 'Đăng ký thành công!');
}
}Luồng hoạt động:
Controller
↓
Mail::to()
↓
WelcomeUser
↓
Blade
↓
SMTP
↓
Email người nhậnCó thể gửi email tới nhiều địa chỉ.
Ví dụ:
Mail::to([
'user1@example.com',
'user2@example.com',
])
->send(new WelcomeUser('Các bạn'));Ngoài to(), hệ thống email còn có thể sử dụng các địa chỉ như:
->cc()và:
->bcc()Ví dụ:
Mail::to($user->email)
->cc('admin@example.com')
->send(
new WelcomeUser($user->name)
);Một email thực tế đôi khi cần gửi:
Hóa đơn
Báo giá
Báo cáo
Hình ảnh
File Excel
Trong Mailable có thể định nghĩa:
public function attachments(): array
{
return [
Attachment::fromPath(
storage_path('app/invoices/invoice.pdf')
),
];
}Import:
use Illuminate\Mail\Mailables\Attachment;Laravel hỗ trợ cấu hình attachment trực tiếp trong Mailable. (Laravel)
Laravel còn hỗ trợ Markdown Mailables.
Thay vì tự viết toàn bộ HTML:
<table>
<tr>
<td>
...chúng ta có thể sử dụng Markdown.
Tạo Mailable:
php artisan make:mail OrderShipped --markdown=emails.orders.shippedLaravel sẽ tạo template tương ứng.
Ví dụ:
resources/views/emails/orders/shipped.blade.phpNội dung:
<x-mail::message>
# Đơn hàng đã được giao
Xin chào {{ $user->name }},
Đơn hàng của bạn đã được giao thành công.
<x-mail::button :url="$url">
Xem đơn hàng
</x-mail::button>
Cảm ơn,<br>
{{ config('app.name') }}
</x-mail::message>Markdown Mailables rất phù hợp với:
Thông báo
Xác nhận đơn hàng
Reset Password
Thông báo hệ thốngMột tính năng rất hữu ích khi thiết kế email là preview trực tiếp trên trình duyệt.
Có thể tạo Route:
use App\Mail\WelcomeUser;
use Illuminate\Support\Facades\Route;
Route::get('/preview-mail', function () {
return new WelcomeUser(
'Nguyễn Văn A'
);
});Sau đó truy cập:
/preview-mailLaravel sẽ render Mailable thành HTML để xem trước mà không cần gửi email thật. (Laravel)
Đây là cách rất tiện để thiết kế email.
Blade Email
↓
Mailable
↓
Browser PreviewKhi test ứng dụng, chúng ta không muốn gửi email thật.
Laravel cung cấp:
Mail::fake();Ví dụ:
use Illuminate\Support\Facades\Mail;
Mail::fake();Sau đó thực hiện hành động gửi email.
Có thể kiểm tra:
Mail::assertSent(
WelcomeUser::class
);Hoặc kiểm tra email không được gửi:
Mail::assertNotSent(
WelcomeUser::class
);Laravel cũng hỗ trợ kiểm tra số lượng Mailable đã gửi. (Laravel)
Đây là phần rất quan trọng.
Giả sử người dùng đăng ký:
Register
↓
Tạo User
↓
Gửi Email
↓
SMTP
↓
Chờ...
↓
ResponseNếu SMTP phản hồi chậm, người dùng có thể phải chờ.
Thay vào đó:
Register
↓
Tạo User
↓
Đưa Email vào Queue
↓
Response ngay
↓
Queue Worker
↓
Gửi EmailMục tiêu là:
Không bắt HTTP request phải chờ việc gửi email hoàn thành.
Mailable có thể được queue để Laravel xử lý việc gửi email ở background.
Ví dụ:
Mail::to($user->email)
->queue(
new WelcomeUser($user->name)
);Đây chính là lúc kiến thức Bài 35 — Queue được kết nối với Bài 36 — Mail.
Trong dự án Blog CMS của chúng ta, có thể xây dựng:
User đăng ký
│
▼
Register
│
▼
Create User
│
▼
WelcomeUser Mail
│
▼
Queue
│
▼
Queue Worker
│
▼
SMTP
│
▼
EmailHoặc:
Admin xuất bản bài viết
│
▼
PostPublished Event
│
▼
Listener
│
▼
SendNewPostMail
│
▼
Queue
│
▼
SubscribersĐây là cách các thành phần Laravel bắt đầu kết nối với nhau thành một hệ thống thực tế.
Khi lập trình local, chúng ta không nên liên tục gửi email thật.
Một giải pháp rất tiện là sử dụng Mailpit để bắt email và xem chúng trên trình duyệt. Tài liệu Laravel/Sail hiện mô tả Mailpit cho môi trường development; giao diện mặc định có thể truy cập qua cổng 8025. (Laravel)
Mô hình:
Laravel
│
▼
Mailpit
│
▼
BrowserThay vì:
Laravel
│
▼
Email thật
│
▼
Khách hàngDevelopment nên dùng:
Local
↓
Mailpit
↓
PreviewProduction mới cấu hình:
Production
↓
SMTP / Mail Provider
↓
UserSau bài này, chúng ta có thể hình dung Laravel Mail như sau:
┌───────────────┐
│ Controller │
└───────┬───────┘
│
▼
┌───────────────┐
│ Mail │
└───────┬───────┘
│
▼
┌───────────────┐
│ Mailable │
└───────┬───────┘
│
┌────────┴────────┐
▼ ▼
Blade View Attachment
│
▼
HTML Mail
│
▼
SMTP / Mailer
│
▼
Người nhậnNếu kết hợp Queue:
Mailable
│
▼
Queue
│
▼
Worker
│
▼
Mailer
│
▼
EmailTạo hệ thống gửi email chào mừng người dùng.
Tạo Mailable:
php artisan make:mail WelcomeUserTạo View:
resources/views/emails/welcome.blade.phpTruyền:
$uservào Mailable.
Thiết kế email:
Xin chào Nguyễn Văn A!
Chào mừng bạn đến với Blog CMS.
[Truy cập website]Tạo route preview:
/preview-mailTest email bằng:
Mail::fake();Cuối cùng chuyển sang Queue:
Mail::to($user->email)
->queue(
new WelcomeUser($user)
);| Thành phần | Vai trò |
|---|---|
Mail | Facade gửi email |
Mailable | Đại diện cho một loại email |
Envelope | Subject và metadata của email |
Content | Xác định nội dung/View |
Blade | Thiết kế email |
Attachment | File đính kèm |
| Markdown Mail | Tạo email bằng Markdown |
| SMTP | Giao thức/server gửi email |
| Queue | Gửi email background |
Mail::fake() | Fake email khi test |
| Mailpit | Xem email khi development |
Sau bài này, chúng ta đã biết cách:
📧 MAIL
│
┌──────────────┼──────────────┐
▼ ▼ ▼
Mailable Blade SMTP
│ │ │
▼ ▼ ▼
Envelope Template Delivery
│
▼
Attachment
│
▼
Queue
│
▼
Background MailĐiểm quan trọng nhất cần nhớ:
Mailable là lớp đại diện cho email.
Blade là nơi xây dựng nội dung email.
Mail facade là nơi gửi email.
Queue giúp việc gửi email không làm chậm request của người dùng.
Và từ đây, chúng ta đã bắt đầu kết nối các kiến thức:
Event
↓
Listener
↓
Queue
↓
Mail
↓
UserĐây chính là kiểu kiến trúc thường gặp trong một ứng dụng Laravel thực tế.
Sau Bài 36 — Mail, bài tiếp theo sẽ là:
Trong bài đó chúng ta sẽ phân biệt:
Mail
Notification
Event
Listener
Queuevà tìm hiểu tại sao Notification có thể gửi thông báo qua nhiều kênh khác nhau thay vì chỉ gửi email.
Tài liệu Laravel 13 cũng tiếp tục hỗ trợ việc kết hợp Notification với Mailable khi cần các email phức tạp hơn. (Laravel)
Lưu ý: Danh sách ban đầu của bạn ghi Laravel 12 LTS, nhưng nếu mục tiêu là khóa học Laravel 2026, mình khuyên đổi toàn bộ tiêu đề thành Laravel 13. Laravel 13 hiện là phiên bản chính thức mới, còn Laravel 12 đã hết thời hạn bug fixes vào 13/08/2026 nhưng vẫn nhận security fixes đến 24/02/2027. (Laravel)
x1
quay về MỤC LỤC
Cập nhật: 2026-09-12T20:10:08.716+07:00
Đừng bắt đầu bằng câu:
“Tôi thích bán gì?”
Hãy bắt đầu bằng:
“Người ta đang bỏ tiền mua gì?”
Có thể tìm ngay từ:
TikTok Shop
Shopee
Các group mua bán
Cửa hàng đối thủ
Quan sát 3 thứ:
① Có người mua thật không?
② Giá bán khoảng bao nhiêu?
③ Người bán đang dùng cách gì để bán?
Chọn 3 sản phẩm.
Ghi:
Sản phẩm:
Giá bán:
Có nhiều người bán không?
Có nhiều người hỏi/mua không?
Tôi có thể bán khác họ ở điểm nào?Không tìm được sản phẩm → chưa chạy quảng cáo.
Có sản phẩm rồi, việc tiếp theo không phải là đăng bài ngay.
Phải tìm:
Khách đang tụ tập ở đâu?
Ví dụ bán đồ câu cá:
Facebook Group
↓
TikTok
↓
YouTube
↓
Google
↓
Forum / cộng đồng câu cáBán đồ ăn:
Facebook địa phương
↓
TikTok địa phương
↓
Google Maps
↓
Group khu dân cưBán đồ cho mẹ và bé:
TikTok
Facebook Group
YouTube
Cộng đồng phụ huynhĐừng cố kéo khách về chỗ mình trước.
Hãy:
Đi tìm nơi khách đã có sẵn.
Sai lầm phổ biến:
“Hôm nay shop có áo mới, giá 199K, inbox shop nhé.”
Ngày nào cũng đăng như vậy.
Khách sẽ lướt qua.
Thay vào đó, hãy đăng thứ khách đang quan tâm.
Ví dụ bán áo:
❌ Áo mới về 199K.
✅ Nam 65kg, cao 1m70 nên mặc size gì?
✅ 3 lỗi khiến áo thun mặc lên bị xấu.
✅ Cùng một chiếc áo nhưng 3 cách phối đồ.
✅ Áo 150K và áo 500K khác nhau ở đâu?Sau đó mới đưa sản phẩm vào.
Mục tiêu của nội dung không nhất thiết là:
“Mua ngay!”
Mà là:
“Người này đang nói đúng thứ mình cần.”
Không cần máy quay.
Không cần studio.
Không cần biết dựng phim chuyên nghiệp.
Một video bán hàng cơ bản:
0–3 giây
↓
Nói vấn đề
3–15 giây
↓
Cho thấy sản phẩm giải quyết vấn đề
15–30 giây
↓
Demo / kết quả
Cuối video
↓
Cách muaVí dụ bán máy hút bụi:
“Nhà có trẻ nhỏ mà sàn cứ đầy tóc? Đây là lý do tôi dùng loại máy này…”
Sau đó quay thật:
Máy hoạt động.
Hút tóc.
Hút bụi.
Đổ hộp bụi.
Cuối cùng:
“Link sản phẩm ở phần mô tả.”
Không cần nói hay. Sản phẩm thật + vấn đề thật + cách giải quyết thật.
Một bài bán hàng đơn giản có thể dùng công thức:
VẤN ĐỀ
↓
GIẢI PHÁP
↓
SẢN PHẨM
↓
BẰNG CHỨNG
↓
GIÁ
↓
CÁCH MUAVí dụ:
Áo trắng mặc vài lần là xuống màu?
Shop vừa về mẫu áo cotton mới, chất vải dày hơn và ít nhăn.
Đây là ảnh chụp thực tế sau khi giặt.
Giá: 199.000đ.
Có size M–XL.
Muốn xem bảng size → comment SIZE.
Điểm quan trọng:
Đừng cố bán cho tất cả mọi người.
Hãy viết cho một người đang có nhu cầu.
Có người xem video nhưng không mua.
Có người xem bài nhưng không hỏi.
Có người vào trang rồi đi.
Ta cần cho họ một lý do để hành động.
Ví dụ:
Xem video
↓
“Muốn biết size?”
↓
Comment SIZE
↓
Shop trả lời
↓
Inbox
↓
Tư vấn
↓
Đơn hàngHoặc:
Video
↓
“Link sản phẩm ở bio”
↓
Trang sản phẩm
↓
Đặt hàngĐừng chỉ nói:
“Hãy ủng hộ shop.”
Hãy nói rõ:
“Muốn mua thì làm gì?”
Đây là chỗ nhiều shop mất tiền nhất.
Có khách hỏi:
“Bao nhiêu vậy shop?”
Shop trả lời:
“199k.”
Hết.
Khách biến mất.
Thay vì vậy:
“Dạ mẫu này 199k ạ. Anh/chị đang tìm size nào để em tư vấn đúng size cho mình? Anh/chị cao và nặng khoảng bao nhiêu ạ?”
Khách trả lời.
Cuộc trò chuyện bắt đầu.
Khách hỏi
↓
Tư vấn
↓
Giải quyết lo lắng
↓
Đề xuất sản phẩm
↓
Chốt đơnMarketing đưa khách tới cửa.
Bán hàng mới là bước lấy tiền.
Học xong phần này, người học không cần thuộc một đống thuật ngữ.
Phải có được:
01 sản phẩm
↓
01 nhóm khách
↓
01 nơi tìm thấy khách
↓
10 nội dung
↓
5 video
↓
các cuộc trò chuyện
↓
🛒 ĐƠN HÀNG ĐẦU TIÊNVà đây mới là điểm bắt đầu của khóa.
Nếu đăng 20 bài mà không có ai hỏi → tìm nguyên nhân.
Nếu có người hỏi nhưng không mua → sửa cách chốt.
Nếu có đơn nhưng không có lời → sửa sản phẩm/giá/chi phí.
Nếu có lời → lúc đó mới đáng để học phần 2: dùng tiền để mua thêm khách.
Không có đơn → chưa cần học cách scale.
Có đơn nhưng lỗ → chưa được scale.
Có đơn và có lời → bắt đầu nghĩ đến quảng cáo.
quay về MỤC LỤC
Cập nhật: 2026-09-11T07:56:15.545+07:00
🔎 Tìm sản phẩm có người mua
👥 Tìm nơi có khách
📱 Biến Facebook/TikTok thành nơi kéo khách
🎬 Làm video bán hàng bằng điện thoại
✍️ Viết nội dung khiến khách hỏi mua
💬 Biến lượt xem thành tin nhắn
🛒 Biến tin nhắn thành đơn hàng
🎯 Chọn sản phẩm để chạy quảng cáo
💵 Chạy quảng cáo với ngân sách nhỏ
🧪 Test quảng cáo
📊 Biết quảng cáo nào đang có lời
🛑 Cắt quảng cáo đang đốt tiền
📈 Tăng tiền cho quảng cáo có lời
🔄 Bán lại cho khách cũ
🎁 Tạo chương trình khiến khách mua thêm
📣 Biến khách hàng thành người giới thiệu
💎 Tăng giá trị mỗi đơn hàng
🧲 Xây danh sách khách hàng riêng
🔎 Đưa sản phẩm lên Google
🏪 Làm trang bán hàng có khả năng chốt đơn
📍 Kéo khách quanh khu vực cửa hàng
🛍️ Bán hàng từ Google Search
🤖 Dùng AI tìm ý tưởng sản phẩm
✍️ Dùng AI sản xuất nội dung
🎬 Dùng AI làm video
📊 Dùng AI phân tích quảng cáo
⚙️ Tự động hóa những việc lặp lại
🧪 Chọn một sản phẩm thật
📱 Dựng kênh bán hàng thật
💰 Chạy chiến dịch thật
📊 Đo tiền vào — tiền ra
🚀 Tối ưu để có lợi nhuận
🏆 Xây hệ thống bán hàng có thể chạy mỗi ngày
Cập nhật: 2026-09-07T20:28:37.699+07:00
Vite là một công cụ dành cho frontend, có nhiệm vụ chính là:
Source Code
↓
Vite
↓
Development Server
↓
Browservà khi đưa website lên production:
CSS / JS / Images
↓
Vite
↓
Build
↓
dist / build assetsVite không phải PHP framework.
Nó cũng không thay thế Laravel, CodeIgniter hay PHP.
Có thể hiểu đơn giản:
Laravel / CodeIgniter / PHP
│
│ Backend
▼
PHP Server
│
│ HTML
▼
Browser
▲
│ CSS / JavaScript
│
ViteVite phụ trách phần frontend asset.
Một dự án Laravel hiện đại thường có rất nhiều frontend asset:
resources/
├── css/
│ └── app.css
│
└── js/
└── app.jsNếu chỉ có một file CSS và một file JS nhỏ thì không vấn đề.
Nhưng dự án lớn có thể có:
JavaScript
├── app.js
├── admin.js
├── dashboard.js
├── modal.js
├── editor.js
└── components/
├── table.js
├── chart.js
└── notification.jsVite giúp quản lý toàn bộ hệ thống này.
Đặc biệt nó hỗ trợ:
ES Modules
CSS
JavaScript
TypeScript
asset processing
development server
HMR
production build
bundling
tối ưu frontend
Một dự án Laravel có thể hình dung:
Laravel
│
├── PHP
│ ├── Route
│ ├── Controller
│ ├── Model
│ └── Middleware
│
├── Blade
│ └── HTML
│
└── Vite
├── CSS
├── JavaScript
├── Images
└── Frontend modulesHai phần này có nhiệm vụ khác nhau.
Request
↓
Route
↓
Controller
↓
Model
↓
Database
↓
Blade
↓
HTMLCSS
JS
Images
Modules
↓
Vite
↓
BrowserDo đó:
Laravel xử lý application. Vite xử lý frontend assets.
Ví dụ Laravel:
my-project/
│
├── app/
├── routes/
├── resources/
│ ├── css/
│ │ └── app.css
│ │
│ └── js/
│ └── app.js
│
├── public/
│
├── package.json
├── vite.config.js
└── .envThông thường source frontend nằm trong:
resources/Trong khi file sau build sẽ được Vite đưa vào vùng public/build.
vite.config.jsVí dụ cấu hình cơ bản của Laravel:
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
export default defineConfig({
plugins: [
laravel({
input: [
'resources/css/app.css',
'resources/js/app.js',
],
refresh: true,
}),
],
});Điểm quan trọng là:
inputxác định những file frontend mà Vite cần xử lý.
Ví dụ:
input: [
'resources/css/app.css',
'resources/js/app.js',
'resources/css/admin.css',
'resources/js/admin.js',
]package.jsonVite hoạt động thông qua Node.js và npm.
Ví dụ:
{
"scripts": {
"dev": "vite",
"build": "vite build"
}
}Chạy:
npm run devđể phát triển.
Còn:
npm run buildđể build production.
Ví dụ:
resources/css/app.cssbody {
font-family: Arial, sans-serif;
}
.card {
border-radius: 10px;
}Trong JavaScript:
import '../css/app.css';Vite sẽ hiểu dependency này.
app.js
│
└── app.cssĐây là một điểm rất quan trọng của Vite:
Frontend asset có thể được quản lý như module.
Ví dụ:
resources/js/app.jsimport './bootstrap';
console.log('Laravel + Vite');Có thể chia module:
resources/js/
├── app.js
├── modal.js
├── table.js
└── chart.jsSau đó:
import './modal';
import './table';
import './chart';Thay vì nhét toàn bộ JavaScript vào một file khổng lồ.
@vite() trong BladeTrong Blade:
@vite([
'resources/css/app.css',
'resources/js/app.js'
])Laravel sẽ kết nối Blade với Vite.
Ở development:
Blade
↓
Vite Dev Server
↓
CSS / JSỞ production:
Blade
↓
Manifest
↓
Built AssetsĐây chính là cầu nối giữa Laravel và Vite.
Đây là phần cần hiểu thật kỹ.
Chạy:
npm run devVite mở development server.
Ví dụ:
Browser
↓
Laravel
↓
Vite
↓
resources/js/app.jsChạy:
npm run buildVite sẽ xử lý:
resources/
↓
Vite
↓
build
↓
optimized CSS / JSVí dụ:
public/build/
├── assets/
│ ├── app-ABC123.js
│ └── app-XYZ456.css
└── manifest.jsonTên file được hash để hỗ trợ cache.
Một trong những tính năng rất hay của Vite là HMR.
Bạn sửa:
console.log('Hello');thành:
console.log('Hello Vite');Vite có thể cập nhật module trên browser mà không cần refresh toàn bộ trang theo cách truyền thống.
Quy trình:
Edit JS
↓
Vite phát hiện thay đổi
↓
HMR
↓
Browser cập nhậtĐây là lý do Vite tạo cảm giác phát triển frontend rất nhanh.
Không dùng Vite, nhiều dự án PHP cũ thường có:
<script src="/js/jquery.js"></script>
<script src="/js/bootstrap.js"></script>
<script src="/js/app.js"></script>
<script src="/js/admin.js"></script>Với Vite:
import './bootstrap';
import './admin';Vite xây dựng dependency graph:
app.js
│
├── bootstrap.js
│
├── modal.js
│
└── admin.jsSau đó build thành asset phù hợp.
Ví dụ:
resources/
├── js/
├── css/
└── images/
└── logo.pngCSS:
.logo {
background-image: url('../images/logo.png');
}Vite có thể xử lý asset dependency này trong quá trình build.
.env và ViteVite cũng hỗ trợ biến môi trường.
Ví dụ:
VITE_APP_NAME="My Laravel App"JavaScript:
console.log(import.meta.env.VITE_APP_NAME);Có một nguyên tắc quan trọng:
Những biến bắt đầu bằng
VITE_có thể được expose ra frontend.
Vì vậy không được đưa secret/private key vào VITE_*.
Laravel cung cấp integration khá chặt với Vite.
Có thể xem kiến trúc:
Laravel
│
├── Blade
│
├── laravel-vite-plugin
│
└── Vite
│
├── CSS
├── JS
├── Images
└── BuildNhưng điều thú vị là:
Vite không hề phụ thuộc Laravel.
Đây chính là chỗ mở rộng kiến thức sang PHP thuần và CI3.
Giả sử dự án:
my-php/
│
├── index.php
├── login.php
│
├── resources/
│ ├── css/
│ │ └── app.css
│ │
│ └── js/
│ └── app.js
│
├── public/
│
├── package.json
└── vite.config.jsKhông cần Laravel.
Vite chỉ cần Node.js.
Ví dụ:
npm install vitepackage.json:
{
"scripts": {
"dev": "vite",
"build": "vite build"
}
}Sau đó:
npm run devPHP vẫn chạy bằng Apache/Nginx/Laragon.
Hai server có thể hoạt động song song:
PHP
127.0.0.1:8000
Vite
localhost:5173Đây mới là phần rất đáng học.
CodeIgniter 3 vốn được xây dựng trong thời kỳ frontend tooling chưa phổ biến như hiện nay.
Một dự án CI3 truyền thống thường có:
application/
system/
public/
assets/và:
<link rel="stylesheet" href="<?= base_url('assets/css/app.css') ?>">
<script src="<?= base_url('assets/js/app.js') ?>"></script>Không có nghĩa là CI3 không dùng được Vite.
Hoàn toàn có thể.
Có thể thiết kế:
CI3
│
├── application/
│ ├── controllers/
│ ├── models/
│ └── views/
│
├── system/
│
├── resources/
│ ├── css/
│ └── js/
│
├── public/
│
├── package.json
└── vite.config.jsSau khi build:
resources/
↓
Vite
↓
public/build/CI3 chỉ cần load file build.
Ví dụ:
<script type="module"
src="<?= base_url('build/assets/app.js') ?>">
</script>Nếu mình xây một project CI3 mới theo hướng hiện đại, có thể tổ chức:
my-ci3/
│
├── application/
│ ├── controllers/
│ ├── models/
│ ├── views/
│ │ ├── layouts/
│ │ ├── admin/
│ │ └── frontend/
│ │
│ └── config/
│
├── resources/
│ ├── css/
│ │ ├── app.css
│ │ └── admin.css
│ │
│ ├── js/
│ │ ├── app.js
│ │ └── admin.js
│ │
│ └── images/
│
├── public/
│ └── build/
│
├── package.json
└── vite.config.jsBackend:
CodeIgniter 3Frontend:
ViteHai hệ thống độc lập nhưng phối hợp với nhau.
Ở máy development:
npm run devKhi deploy:
npm run buildSau đó server PHP không cần chạy Vite.
Chỉ cần các file đã build:
public/build/Đây là điểm rất quan trọng.
Production có thể chạy:
Apache/Nginx
│
├── PHP
│ ↓
│ CI3
│
└── Static Assets
↓
Vite buildVite không phải backend server production của CI3.
Sai:
Vite = Framework PHPĐúng:
Vite = Frontend build tool / development serverVITE_Không nên:
VITE_SECRET_KEY=abc123vì biến này có thể xuất hiện phía frontend.
npm run devKhông cần.
Production nên:
npm run buildsau đó server phục vụ file build.
@vite()Đúng.
@vite() là integration của Laravel.
Với CI3 bạn phải tự xây cách load asset:
<script type="module"
src="<?= base_url('build/assets/app.js') ?>">
</script>Có thể hình dung:
Laravel cũ
↓
Laravel Mix
↓
WebpackLaravel hiện đại:
Laravel
↓
Vite
↓
esbuild / Rollup ecosystemVite có workflow frontend hiện đại hơn và development server nhanh hơn.
Không nhất thiết dự án PHP nào cũng cần Vite.
PHP
├── HTML
├── CSS
└── vài dòng JSCó thể không cần.
PHP
+
Bootstrap
+
Chart.js
+
DataTables
+
Modules JS
+
SCSSVite bắt đầu rất hữu ích.
Laravel / CI3 / PHP API
+
React / Vue
+
Vitethì Vite càng phù hợp.
Điểm quan trọng nhất của bài này không phải là:
“Laravel dùng Vite như thế nào?”
mà là hiểu rằng Vite có thể đứng độc lập với PHP framework.
Ta có thể xây:
FRONTEND
│
Vite
│
┌───────────┼───────────┐
│ │ │
CSS JS Images
│ │
└───────────┼───────────┘
│
Build
│
▼
Browser
▲
│
HTML
│
┌───────────┼───────────┐
│ │ │
Laravel CI3 PHP thuần
│ │ │
└───────────┼───────────┘
│
DatabaseĐây là tư duy rất đáng học:
Vite không thuộc riêng Laravel. Laravel chỉ cung cấp một integration thuận tiện với Vite.
Vì vậy sau khi học Vite trong Laravel, ta hoàn toàn có thể lấy kiến thức đó áp dụng cho CodeIgniter 3.x, PHP thuần, Symfony hoặc bất kỳ backend PHP nào.
Cập nhật: 2026-09-06T15:05:20.673+07:00
Trong các bài trước, chúng ta đã làm việc trực tiếp với:
Controller
Model
Event
Listener
Observer
Notification
Tuy nhiên, có một vấn đề:
Nếu một công việc mất nhiều thời gian, chúng ta có muốn người dùng phải chờ cho đến khi công việc hoàn thành hay không?
Ví dụ:
Người dùng tạo bài viết
↓
Laravel lưu bài viết
↓
Gửi email
↓
Tạo thumbnail
↓
Tạo thông báo
↓
Ghi log
↓
Trả Response
Nếu tất cả đều chạy ngay trong HTTP Request, người dùng có thể phải chờ rất lâu.
Laravel Queue giải quyết vấn đề này bằng cách đưa những công việc cần xử lý sau vào hàng đợi.
Mô hình:
HTTP Request
│
▼
Lưu dữ liệu
│
▼
Dispatch Job
│
├──────────────► Response
│
▼
Queue
│
▼
Worker
│
▼
Xử lý công việc📦 Tạo Job
Queue nghĩa là hàng đợi.
Trong Laravel, Queue cho phép chúng ta đưa một công việc vào hàng đợi để xử lý sau thay vì xử lý ngay trong HTTP Request.
Ví dụ:
User
│
│ Tạo bài viết
▼
Laravel
│
├── Lưu Post
│
├── Dispatch Job
│
▼
Response
│
│
▼
Queue
│
▼
Worker
│
▼
Gửi Email
Ví dụ công việc phù hợp với Queue:
📧 Gửi email
🖼️ Resize ảnh
📊 Tạo báo cáo
📦 Import dữ liệu
🔔 Gửi notification
🔄 Đồng bộ dữ liệu
🧮 Xử lý dữ liệu lớn
Queue có thể hình dung rất đơn giản:
JOB
│
▼
QUEUE
│
▼
WORKER
│
▼
PROCESSING
Ví dụ:
SendWelcomeEmail
↓
Queue
↓
Worker
↓
Send Email
Điểm quan trọng:
Queue không tự xử lý Job.
Queue chỉ lưu công việc đang chờ.
Một Worker phải lấy Job ra và xử lý.
Đây là ba khái niệm quan trọng nhất.
Job mô tả:
Công việc cần thực hiện.
Ví dụ:
SendWelcomeEmail
GenerateThumbnail
GenerateReport
Queue là:
Nơi lưu những Job đang chờ xử lý.
Ví dụ:
Queue
├── Job A
├── Job B
├── Job C
└── Job D
Worker là:
Process chạy liên tục để lấy Job từ Queue và xử lý.
Queue
│
▼
Worker
│
├── Job A
├── Job B
└── Job C
Có thể ghi nhớ:
Job = Làm gì?
Queue = Chờ ở đâu?
Worker = Ai làm?
Queue Driver quyết định Laravel sẽ lưu Job ở đâu.
Trong Laravel, cấu hình Queue nằm trong:
config/queue.php
Ví dụ:
QUEUE_CONNECTION=database
Có nghĩa Laravel sử dụng:
database
làm Queue Connection mặc định.
Có thể hình dung:
Laravel
│
▼
Queue
│
▼
Driver
│
├── database
├── redis
├── sqs
└── sync
Một số Queue Connection thường gặp:
syncJob chạy ngay lập tức.
QUEUE_CONNECTION=sync
Mô hình:
Request
↓
Job
↓
Xử lý ngay
↓
Response
Không thực sự có hàng đợi.
databaseJob được lưu vào database.
QUEUE_CONNECTION=database
Mô hình:
Job
↓
jobs table
↓
Worker
↓
Processing
Đây là lựa chọn rất dễ học và phù hợp để thực hành.
redisJob được lưu trong Redis.
QUEUE_CONNECTION=redis
Redis thường phù hợp với hệ thống cần Queue có hiệu năng cao.
Laravel cũng hỗ trợ Amazon SQS cho hệ thống triển khai trên AWS.
File cấu hình chính:
config/queue.php
Connection mặc định thường lấy từ:
QUEUE_CONNECTION=database
Sau khi thay đổi .env, có thể cần xóa config cache:
php artisan config:clear
Kiểm tra cấu hình:
php artisan config:show queue
Tư duy:
.env
↓
config/queue.php
↓
Laravel Queue
Nếu sử dụng Database Queue, Laravel cần bảng để lưu Job.
Trong các project Laravel mới, migration liên quan đến Queue có thể đã tồn tại sẵn.
Ghi chú: chính là xxxcreate_jobs_table (trong database là 03 bảng có chữ job)
Trước tiên nên kiểm tra:
database/migrations/
Nếu project chưa có migration cho Queue, có thể tạo bằng:
php artisan make:queue-table
Sau đó:
php artisan migrate
Laravel sẽ có bảng:
jobs
Có thể hình dung:
jobs
├── id
├── queue
├── payload
├── attempts
├── reserved_at
└── available_at
Job được dispatch sẽ được lưu vào đây khi sử dụng Database Queue.
Laravel cung cấp Artisan command:
php artisan make:job SendWelcomeEmail
Laravel tạo:
app/
└── Jobs/
└── SendWelcomeEmail.php
Một Job có thể được viết:
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class SendWelcomeEmail implements ShouldQueue
{
use Queueable;
public function handle(): void
{
// Gửi email
}
}
Điểm quan trọng là:
implements ShouldQueue
Nó cho Laravel biết Job này được thiết kế để chạy thông qua Queue.
handle() là gì?handle() là nơi Laravel thực hiện công việc của Job.
Ví dụ:
public function handle(): void
{
// Gửi email
}
Hoặc:
public function handle(): void
{
User::query()
->where('is_active', true)
->update([
'last_synced_at' => now(),
]);
}
Tư duy:
dispatch()
↓
Queue
↓
Worker
↓
handle()
Worker lấy Job ra và Laravel gọi:
$job->handle();
theo cơ chế nội bộ của Queue.
Job thường cần dữ liệu.
Ví dụ muốn gửi email cho một User.
class SendWelcomeEmail implements ShouldQueue
{
use Queueable;
public function __construct(
public int $userId
) {
}
public function handle(): void
{
$user = User::findOrFail($this->userId);
// Gửi email
}
}
Khi dispatch:
SendWelcomeEmail::dispatch($user->id);
Dữ liệu:
$user->id
↓
Job constructor
↓
$userId
↓
handle()
Sau khi tạo Job:
SendWelcomeEmail::dispatch($user->id);
Laravel đưa Job vào Queue.
Nếu:
QUEUE_CONNECTION=database
thì Job sẽ được lưu vào database.
Mô hình:
Controller
│
▼
dispatch()
│
▼
jobs
│
▼
Worker
│
▼
handle()
Đây là một trong những dòng code quan trọng nhất của Queue:
SendWelcomeEmail::dispatch($user->id);
Đây là lý do chính chúng ta sử dụng Queue.
Không dùng Queue:
Browser
│
▼
Controller
│
▼
Gửi email
│
▼
Xử lý ảnh
│
▼
Tạo report
│
▼
Response
Người dùng phải chờ.
Có Queue:
Browser
│
▼
Controller
│
├── Lưu dữ liệu
│
├── Dispatch Job
│
▼
Response nhanh
Sau đó:
Queue
↓
Worker
↓
Job
↓
Xử lý
Đây là tư duy quan trọng:
Request không cần trực tiếp làm mọi việc.
Dispatch Job chưa có nghĩa là Job đã được xử lý.
Nếu dùng Database Queue, cần Worker:
php artisan queue:work
Worker sẽ:
jobs
↓
Job
↓
handle()
↓
done
Terminal có thể hiển thị:
INFO Processing jobs from the [default] queue.
Để học Queue, nên mở một terminal riêng chạy Worker.
Worker không chỉ chạy một Job.
Thông thường:
php artisan queue:work
sẽ chạy liên tục:
Job A
↓
Job B
↓
Job C
↓
Job D
↓
...
Có thể giới hạn số Job:
php artisan queue:work --once
Khi đó Worker xử lý một Job rồi kết thúc.
Mô hình:
queue:work
│
├── Job A
├── Job B
├── Job C
└── Job D
Không phải Job nào cũng cần chạy ngay.
Laravel cho phép delay Job.
Ví dụ:
SendWelcomeEmail::dispatch($user->id)
->delay(now()->addMinutes(5));
Mô hình:
dispatch
↓
Queue
↓
5 phút
↓
Worker xử lý
Ví dụ thực tế:
User đăng ký
↓
Chờ 5 phút
↓
Gửi email hướng dẫn
Delay rất hữu ích cho những công việc không cần thực hiện ngay lập tức.
Một ứng dụng thực tế thường có nhiều Job.
Ví dụ Blog CMS:
app/Jobs/
├── SendPostNotification.php
├── GeneratePostThumbnail.php
├── GeneratePostReport.php
└── SendWelcomeEmail.php
Queue:
Queue
│
├── SendPostNotification
├── GeneratePostThumbnail
├── GeneratePostReport
└── SendWelcomeEmail
Worker có thể xử lý từng Job.
Khi hệ thống lớn hơn, chúng ta có thể chia thành nhiều Queue:
notifications
images
reports
emails
Queue rất hay khi kết hợp với Event & Listener.
Ví dụ:
PostPublished
↓
Listener
↓
SendPostNotification Job
↓
Queue
↓
Worker
Controller chỉ cần:
event(new PostPublished($post));
Listener có thể dispatch Job:
SendPostNotification::dispatch($post->id);
Mô hình:
Controller
↓
Event
↓
Listener
↓
Job
↓
Queue
↓
Worker
Điều này giúp Controller không phải chứa quá nhiều logic.
Hãy áp dụng Queue vào Blog CMS.
Khi Admin publish bài viết:
Admin
↓
Publish Post
↓
PostController
↓
Save Post
↓
Event: PostPublished
↓
Listener
↓
Dispatch Jobs
Có thể có:
SendPostNotification
GeneratePostThumbnail
UpdateSearchIndex
Queue:
notifications
images
search
Worker xử lý phía sau.
Kết quả:
Admin
↓
Publish
↓
Response nhanh
↓
Queue
↓
Email
↓
Thumbnail
↓
Search Index
Đây là một ứng dụng thực tế rất phù hợp với Blog CMS.
Job không phải lúc nào cũng thành công.
Ví dụ:
Worker
↓
Send Email
↓
SMTP lỗi
↓
Job FAILED
Laravel có cơ chế quản lý Failed Jobs.
Một Job có thể thất bại vì:
API không hoạt động
Database lỗi
Email server lỗi
Network timeout
Dữ liệu không hợp lệ
Exception trong handle()
Không nên xem Failed Job là điều bất thường hoàn toàn.
Trong hệ thống Queue thực tế:
Failure là một phần cần được thiết kế để xử lý.
Laravel hỗ trợ bảng:
failed_jobs
Nếu project chưa có migration Failed Jobs, có thể tạo:
php artisan make:queue-failed-table
Sau đó:
php artisan migrate
Kiểm tra Failed Jobs:
php artisan queue:failed
Laravel sẽ hiển thị những Job thất bại.
Ví dụ:
UUID
Connection
Queue
Class
Failed At
Một Job thất bại không nhất thiết phải bỏ đi.
Có thể retry:
php artisan queue:retry all
Hoặc retry một Job cụ thể:
php artisan queue:retry <uuid>
Mô hình:
Job
↓
FAILED
↓
Retry
↓
Worker
↓
SUCCESS
Đây là một trong những ưu điểm lớn của Queue.
failed()Job có thể định nghĩa:
public function failed(?Throwable $exception): void
{
// Xử lý khi Job thất bại
}
Ví dụ:
public function failed(?Throwable $exception): void
{
Log::error(
'SendWelcomeEmail failed',
[
'user_id' => $this->userId,
'error' => $exception?->getMessage(),
]
);
}
Có thể sử dụng để:
Ghi Log
Gửi cảnh báo
Cập nhật trạng thái
Thông báo Admin
Mô hình:
handle()
↓
Exception
↓
failed()
Khi có nhiều Queue:
high
default
low
Worker có thể ưu tiên queue:
php artisan queue:work --queue=high,default,low
Worker sẽ ưu tiên:
high
↓
default
↓
low
Ví dụ:
high
├── Payment
└── SecurityNotification
default
├── Email
└── Notification
low
├── Report
└── Analytics
Điểm cần nhớ:
Priority quyết định Worker xử lý queue nào trước.
Laravel 13 bổ sung khả năng Queue Routing theo Job class thông qua:
Queue::route(...)
Trước tiên:
use Illuminate\Support\Facades\Queue;
Sau đó có thể khai báo:
Queue::route(
SendWelcomeEmail::class,
connection: 'redis',
queue: 'emails',
);
Có nghĩa:
SendWelcomeEmail
↓
Redis
↓
emails
Khi dispatch:
SendWelcomeEmail::dispatch($user->id);
Job có thể sử dụng route mặc định đã khai báo.
Đừng nhầm hai khái niệm:
Queue Routing
↓
Job đi đâu?
Queue Priority
↓
Queue nào xử lý trước?
Ví dụ:
GeneratePostThumbnail
│
│ Routing
▼
images
│
│ Worker Priority
▼
Worker xử lý queue
Queue Routing đặc biệt hữu ích khi ứng dụng có nhiều loại Job:
emails
images
reports
notifications
Laravel 13 cho phép chúng ta tổ chức việc định tuyến Job rõ ràng hơn thay vì phải lặp lại cấu hình queue ở nhiều nơi.
Trong môi trường phát triển, chúng ta có thể chạy:
php artisan queue:work
Nhưng Production không nên phụ thuộc vào việc người quản trị mở Terminal rồi chạy Worker thủ công.
Thông thường Worker được quản lý bởi process manager như:
Supervisor
Mô hình:
Production Server
│
▼
Supervisor
│
▼
queue:work
│
▼
Queue
Nếu Worker bị crash, process manager có thể khởi động lại.
Production còn cần quan tâm:
Worker restart
Memory limit
Timeout
Retry
Failed Jobs
Logging
Monitoring
Với hệ thống Laravel lớn, Laravel Horizon cũng là một công cụ quan trọng khi sử dụng Redis Queue.
Đây là một hiểu lầm phổ biến.
Khi viết:
SendWelcomeEmail::dispatch();
không có nghĩa PHP process hiện tại tự biến thành một background thread.
Đúng hơn:
HTTP Request
│
▼
Dispatch Job
│
▼
Queue lưu Job
│
▼
HTTP Request kết thúc
Một process khác:
Worker
↓
Lấy Job
↓
handle()
Vì vậy:
Queue tạo ra cơ chế xử lý bất đồng bộ thông qua một Worker riêng.
Đây là lý do cần chạy:
php artisan queue:work
sync và databaseĐây là ví dụ rất dễ hiểu.
syncQUEUE_CONNECTION=sync
Mô hình:
Request
↓
dispatch()
↓
Job chạy ngay
↓
Response
databaseQUEUE_CONNECTION=database
Mô hình:
Request
↓
dispatch()
↓
jobs
↓
Response
Worker
↓
jobs
↓
handle()
So sánh:
| sync | database | |
|---|---|---|
| Lưu Job | Không | Có |
| Worker cần thiết | Không | Có |
| Xử lý bất đồng bộ | Không | Có |
| Dễ học | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| Production Queue | Không phù hợp | Có thể |
Nếu đang học Queue:
QUEUE_CONNECTION=database
là một lựa chọn rất dễ quan sát.
Hãy xây dựng một Job gửi thông báo khi bài viết được publish.
php artisan make:job SendPostNotification
<?php
namespace App\Jobs;
use App\Models\Post;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class SendPostNotification implements ShouldQueue
{
use Queueable;
public function __construct(
public int $postId
) {
}
public function handle(): void
{
$post = Post::findOrFail($this->postId);
// Gửi notification
// ...
}
}
Trong Controller:
SendPostNotification::dispatch($post->id);
php artisan queue:work
Admin
↓
Publish Post
↓
Controller
↓
dispatch()
↓
jobs
↓
Response
Worker
↓
jobs
↓
SendPostNotification
↓
handle()
↓
Done
Đây chính là Queue từ đầu đến cuối.
Queue không đứng một mình.
Nó có thể kết hợp:
Controller
↓
Service
↓
Event
↓
Listener
↓
Job
↓
Queue
↓
Worker
↓
External Service
Ví dụ Blog CMS:
PostController
↓
Publish Post
↓
PostPublished Event
↓
Listener
↓
SendPostNotification
↓
Queue
↓
Worker
↓
Email / Notification
Mỗi thành phần có một trách nhiệm.
Đây là tư duy quan trọng của Laravel:
Không phải Controller phải làm tất cả mọi việc.
Nên dùng Queue khi công việc:
Ví dụ:
Generate PDF
Process Image
Export Excel
Ví dụ:
Send Email
Send Notification
Generate Report
Ví dụ:
100.000 users
↓
100.000 emails
Không nên xử lý tất cả trong một HTTP Request.
Có thể chia thành:
User 1 → Job
User 2 → Job
User 3 → Job
...
Không phải công việc nào cũng cần Queue.
Ví dụ:
$post = Post::create($data);
Việc này cần hoàn thành ngay để biết Post đã được tạo hay chưa.
Không nên biến mọi thứ thành Queue.
Ví dụ:
Validate Request
↓
Queue
↓
Save Post
là không hợp lý trong một Request thông thường.
Nguyên tắc:
Nếu Response cần kết quả ngay, thường không nên đẩy công việc đó sang Queue.
Queue phù hợp hơn với công việc:
"Không cần hoàn thành ngay."
Kiểm tra Worker:
php artisan queue:work
syncKiểm tra:
QUEUE_CONNECTION=sync
Nếu đang dùng sync, Job sẽ chạy ngay.
jobsNếu dùng Database Queue:
jobs
phải tồn tại.
Kiểm tra migration và chạy:
php artisan migrate
Kiểm tra:
php artisan queue:failed
Sau đó retry:
php artisan queue:retry all
Sau khi thay đổi .env hoặc cấu hình Queue, cần chú ý Worker đang chạy có thể vẫn giữ configuration cũ.
Trong quá trình vận hành, Worker thường cần được restart đúng cách sau các thay đổi cấu hình/code.
Đừng học Queue bằng cách nhớ hàng loạt command.
Hãy hiểu dòng chảy:
1. Tạo Job
↓
2. Dispatch Job
↓
3. Queue lưu Job
↓
4. Worker lấy Job
↓
5. handle()
↓
6. Success / Failed
Nếu hiểu được sơ đồ này thì phần lớn Queue đã trở nên dễ hiểu.
Tiếp theo mới học:
Delay
Retry
Priority
Routing
Failed Jobs
Worker
Production
Một cách ghi nhớ khác:
Job
↓
"Việc gì?"
Queue
↓
"Chờ ở đâu?"
Connection
↓
"Lưu bằng gì?"
Routing
↓
"Job này đi đâu?"
Priority
↓
"Xử lý cái nào trước?"
Worker
↓
"Ai xử lý?"
Retry
↓
"Nếu thất bại thì sao?"
Sau bài này, chúng ta đã biết:
Hệ thống hàng đợi để xử lý công việc sau.
Đại diện cho công việc cần thực hiện.
Process lấy Job ra xử lý.
Quyết định nơi Queue được lưu trữ.
Cho phép Job chạy sau một khoảng thời gian.
Quản lý những Job thất bại.
Cho phép chạy lại Job.
Quy định queue nào được Worker ưu tiên.
Laravel 13 cho phép định tuyến Job class tới Queue/Connection thông qua:
Queue::route(...)
Tạo:
php artisan make:job SendPostNotification
Cho Job nhận:
$postId
Trong Controller:
SendPostNotification::dispatch($post->id);
Cấu hình:
QUEUE_CONNECTION=database
Đảm bảo database có bảng:
jobs
Chạy:
php artisan queue:work
Sau đó tạo một Post và quan sát Worker xử lý Job.
Cố tình tạo một Exception trong handle().
Sau đó kiểm tra:
php artisan queue:failed
Thử:
php artisan queue:retry all
Tạo hai queue:
high
low
Chạy:
php artisan queue:work --queue=high,low
Quan sát thứ tự xử lý.
Tạo một Job:
GeneratePostThumbnail
Sau đó thiết lập route:
Queue::route(
GeneratePostThumbnail::class,
connection: 'redis',
queue: 'images',
);
Sau đó dispatch:
GeneratePostThumbnail::dispatch($post->id);
Hãy tự trả lời:
Job đi đâu?
Connection nào?
Queue nào?
Worker nào xử lý?
Priority có liên quan gì?
Queue là 01 trong những cơ chế quan trọng khi xây dựng ứng dụng Laravel thực tế.
Thay vì:
Request
↓
Làm tất cả
↓
Response
chúng ta có:
Request
↓
Lưu dữ liệu
↓
Dispatch Job
↓
Response
↓
Queue
↓
Worker
↓
Job
↓
Processing
Khi kết hợp với:
Event
Listener
Job
Queue
Worker
Laravel có thể xây dựng những hệ thống xử lý công việc phía sau rất linh hoạt.
Và với Laravel 13, chúng ta có thêm:
Queue::route(...)
để định tuyến Job theo class một cách rõ ràng hơn.
Công thức cuối cùng cần nhớ:
JOB
↓
QUEUE
↓
WORKER
↓
HANDLE()
↓
SUCCESS / FAILED
Đây chính là nền tảng để bước sang những kiến trúc Laravel lớn hơn.
x1
quay về MỤC LỤC
Cập nhật: 2026-09-08T20:40:07.285+07:00
Ở bài trước, chúng ta đã học Observer.
Observer cho phép chúng ta phản ứng khi một Eloquent Model xảy ra sự kiện:
Post
│
├── created
├── updated
├── deleted
└── restored
│
▼
PostObserver
Ví dụ:
public function created(Post $post): void
{
logger("Post {$post->id} đã được tạo");
}
Observer rất hữu ích.
Tuy nhiên, trong ứng dụng thực tế sẽ có những sự kiện không nhất thiết phải gắn trực tiếp với Model.
Ví dụ:
User đăng ký tài khoản → gửi email chào mừng.
Đơn hàng được đặt → gửi thông báo.
Bài viết được publish → ghi log.
Thanh toán thành công → cập nhật dữ liệu.
User đăng ký → tạo một số dữ liệu liên quan.
Post được publish → thông báo cho người quản trị.
Lúc này chúng ta có thể sử dụng:
Event & Listener
Mô hình:
Một hành động xảy ra
│
▼
Event
│
▼
Listener
│
├── Gửi email
├── Ghi log
├── Gửi notification
└── Xử lý dữ liệu🏗️ Tạo Event
Event có thể hiểu đơn giản là:
Một thông báo cho biết một điều gì đó đã xảy ra trong ứng dụng.
Ví dụ:
UserRegistered
PostPublished
OrderCreated
PaymentCompleted
Event không nhất thiết phải trực tiếp xử lý công việc.
Nó chỉ mang thông tin:
"User vừa đăng ký"
Sau đó Listener sẽ nhận Event và thực hiện công việc.
Ví dụ:
User đăng ký
│
▼
UserRegistered
│
▼
SendWelcomeEmail
Ở đây:
UserRegistered
là Event.
Còn:
SendWelcomeEmail
là Listener.
Hãy tưởng tượng Controller có:
public function register(Request $request)
{
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => bcrypt($request->password),
]);
// Gửi email
// Ghi log
// Gửi notification
// Tạo dữ liệu khác
// Xử lý thêm...
}
Ban đầu nhìn không có vấn đề gì.
Nhưng ứng dụng càng lớn, Controller sẽ càng dài.
Ví dụ:
RegisterController
│
├── Create User
├── Send Email
├── Write Log
├── Create Profile
├── Send Notification
└── ...
Chúng ta có thể tách ra:
RegisterController
│
▼
UserRegistered Event
│
├── SendWelcomeEmail
├── CreateUserProfile
└── LogUserRegistration
Controller chỉ cần phát ra Event.
Đây là điểm rất quan trọng.
Observer thường gắn với:
Eloquent Model
Ví dụ:
Post
│
└── PostObserver
Observer quan tâm đến:
created
updated
deleted
restored
Trong khi Event có thể đại diện cho một nghiệp vụ của toàn bộ ứng dụng.
Ví dụ:
UserRegistered
PostPublished
OrderPaid
PaymentCompleted
Có thể hình dung:
Observer
Post
│
└── updated
│
▼
PostObserver
Event
PostController
│
└── PostPublished
│
▼
Listener
Observer:
"Model Post vừa xảy ra chuyện gì?"
Event:
"Trong ứng dụng vừa xảy ra nghiệp vụ gì?"
Laravel cung cấp Artisan command:
php artisan make:event UserRegistered
📋 Copy
Laravel sẽ tạo:
app/
└── Events/
└── UserRegistered.php
📋 Copy
Một Event cơ bản:
<?php
namespace App\Events;
class UserRegistered
{
//
}
📋 Copy
Đây là một Event.
Nhưng hiện tại Event chưa chứa thông tin gì.
Chúng ta sẽ truyền dữ liệu vào Event ở phần tiếp theo.
Thông thường Event cần mang theo dữ liệu.
Ví dụ:
$user = User::find(1);
Chúng ta muốn Event biết User nào vừa đăng ký.
Event:
<?php
namespace App\Events;
use App\Models\User;
class UserRegistered
{
public function __construct(
public User $user
) {
}
}
📋 Copy
Bây giờ Event chứa:
UserRegistered
│
└── user
Khi tạo Event:
event(new UserRegistered($user));
📋 Copy
Listener có thể lấy:
$event->user
Sau khi có Event, chúng ta cần phát nó.
Laravel cung cấp helper:
event(new UserRegistered($user));
📋 Copy
Ví dụ:
public function register(Request $request)
{
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => bcrypt($request->password),
]);
event(new UserRegistered($user));
return redirect()->route('login');
}
📋 Copy
Luồng:
User đăng ký
│
▼
User::create()
│
▼
UserRegistered
│
▼
Listeners
Event được phát ra sau khi User được tạo.
Ghi chú: file RegisteredUserController.php này là nơi bạn cần sửa. Và store() chính là phiên bản register() tương ứng trong Breeze.
Nếu Event là:
"User đã đăng ký"
thì Listener là thành phần:
"Vậy tôi sẽ làm gì khi User đăng ký?"
Ví dụ:
UserRegistered
│
▼
SendWelcomeEmail
Listener nhận Event và xử lý.
Tạo Listener:
php artisan make:listener SendWelcomeEmail --event=UserRegistered
📋 Copy
Laravel tạo:
app/
└── Listeners/
└── SendWelcomeEmail.php
📋 Copy
Listener cơ bản:
<?php
namespace App\Listeners;
use App\Events\UserRegistered;
class SendWelcomeEmail
{
public function handle(UserRegistered $event): void
{
//
}
}
📋 Copy
Phương thức quan trọng nhất là:
handle()
Nó nhận Event:
public function handle(UserRegistered $event): void
{
//
}
📋 Copy
Sau đó có thể lấy User:
$event->user
Ví dụ:
public function handle(UserRegistered $event): void
{
$user = $event->user;
logger("User {$user->id} vừa đăng ký");
}
📋 Copy
Bây giờ chúng ta có:
UserRegistered
và:
SendWelcomeEmail
Quan hệ:
UserRegistered
│
▼
SendWelcomeEmail
Khi:
event(new UserRegistered($user));
📋 Copy
Laravel sẽ tìm các Listener đang lắng nghe Event này.
Sau đó gọi:
handle()
Luồng hoàn chỉnh:
Controller
│
▼
event(new UserRegistered($user))
│
▼
UserRegistered
│
▼
SendWelcomeEmail
│
▼
handle(UserRegistered $event)
Laravel cần biết:
Event nào
↓
Listener nào
Trong Laravel hiện đại, việc khai báo có thể được thực hiện thông qua Event discovery hoặc cấu hình Event/Listener tùy cấu trúc ứng dụng.
Với project học tập, điều quan trọng trước tiên là hiểu mối quan hệ:
Event
│
├── Listener A
├── Listener B
└── Listener C
Một Event hoàn toàn có thể có nhiều Listener.
Ví dụ:
UserRegistered
│
├── SendWelcomeEmail
├── CreateUserProfile
└── LogRegistration
Một sự kiện có thể kích hoạt nhiều công việc độc lập.
Đây là một trong những ưu điểm lớn của Event.
Ví dụ:
PostPublished
│
├── SendNotification
├── WriteActivityLog
├── UpdateStatistics
└── NotifySubscribers
Controller chỉ cần:
event(new PostPublished($post));
📋 Copy
Không cần:
sendNotification();
writeActivityLog();
updateStatistics();
notifySubscribers();
📋 Copy
Controller chỉ phát ra:
PostPublished
Các Listener tự xử lý phần việc của mình.
Trong Blog CMS của chúng ta, giả sử khi Post được publish:
Post Published
│
▼
PostPublished
Chúng ta muốn:
PostPublished
│
├── Ghi log
├── Gửi notification
└── Cập nhật thống kê
Tạo Event:
php artisan make:event PostPublished
📋 Copy
Event:
<?php
namespace App\Events;
use App\Models\Post;
class PostPublished
{
public function __construct(
public Post $post
) {
}
}
📋 Copy
PostController.phpĐầu file thêm:
use App\Events\PostPublished;
Sau đó trong store():
public function store(Request $request) { // validate... $post = Post::create([ 'title' => $request->title, 'content' => $request->content, 'status' => $request->status, 'category_id' => $request->category_id, ]); if ($post->status === 'published') { event(new PostPublished($post)); } return redirect() ->route('posts.index') ->with('success', 'Tạo bài viết thành công.'); }
Publish Post
│
▼
PostController
│
▼
Post::store()
│
▼
PostPublished
│
├── Listener
├── Listener
└── Listener
Tạo:
php artisan make:listener LogPublishedPost --event=PostPublished
📋 Copy
Listener:
<?php
namespace App\Listeners;
use App\Events\PostPublished;
class LogPublishedPost
{
public function handle(PostPublished $event): void
{
logger(
"Post {$event->post->id} đã được publish"
);
}
}
📋 Copy
Khi Event:
event(new PostPublished($post));
📋 Copy
Listener sẽ nhận:
$event->post
và ghi Log.
Chúng ta có thể tạo Listener khác:
php artisan make:listener NotifyPostPublished --event=PostPublished
📋 Copy
Ví dụ:
<?php
namespace App\Listeners;
use App\Events\PostPublished;
class NotifyPostPublished
{
public function handle(PostPublished $event): void
{
$post = $event->post;
// Gửi notification
}
}
📋 Copy
Bây giờ:
PostPublished
│
├── LogPublishedPost
│
└── NotifyPostPublished
Một Event → nhiều Listener.
Đây là một điểm người mới học Laravel rất dễ nhầm.
Service thường được gọi khi chúng ta chủ động muốn thực hiện một nghiệp vụ.
Ví dụ:
$postService->publish($post);
📋 Copy
Còn Event:
event(new PostPublished($post));
📋 Copy
mang ý nghĩa:
"Post đã được publish."
Listener:
"Tôi nghe sự kiện đó và thực hiện công việc của tôi."
Có thể hình dung:
Service
Controller
│
▼
Service
│
▼
Thực hiện nghiệp vụ
Event
Controller
│
▼
Event
│
▼
Listener
│
▼
Phản ứng với sự kiện
Observer chủ yếu xoay quanh Eloquent Model Event.
Event thì rộng hơn.
Ví dụ:
UserRegistered
PostPublished
OrderPaid
PaymentFailed
PasswordChanged
ImportCompleted
ReportGenerated
Những sự kiện này có thể liên quan đến:
Model
Controller
Service
Job
Command
API
Event giúp mô tả những gì đang xảy ra trong ứng dụng.
Một Listener có thể thực hiện công việc nặng.
Ví dụ:
PostPublished
│
▼
GeneratePostImage
│
▼
Xử lý ảnh lớn
Nếu xử lý trực tiếp:
User
│
▼
Publish Post
│
▼
Listener
│
▼
Xử lý 20 giây
│
▼
Response
Người dùng phải chờ.
Trong trường hợp này, Listener có thể được đưa vào Queue.
Ví dụ Listener implement:
ShouldQueue
Khi đó:
User
│
▼
Publish Post
│
▼
Event
│
▼
Queued Listener
│
└──────► Queue
│
▼
Worker
│
▼
Xử lý nền
Đây là lý do Event & Listener thường xuất hiện trước phần Queue trong kiến trúc Laravel.
Event & Listener giúp tách code.
Nhưng không có nghĩa:
Listener
↓
500 dòng code
là tốt.
Ví dụ:
class PostPublishedListener
{
public function handle(PostPublished $event): void
{
// 300 dòng xử lý...
}
}
Không nên.
Nếu công việc lớn, có thể tách:
Listener
│
├── Service
├── Job
└── Action
Ví dụ:
public function handle(PostPublished $event): void
{
GeneratePostStatistics::run($event->post);
}
Listener trở thành nơi kết nối thay vì chứa toàn bộ nghiệp vụ.
Đây là bảng cần nhớ:
| Observer | Event & Listener |
|---|---|
| Gắn với Model | Gắn với nghiệp vụ/sự kiện ứng dụng |
| Theo dõi Eloquent Event | Phát ra Event tùy ý |
created | UserRegistered |
updated | PostPublished |
deleted | OrderPaid |
restored | PaymentCompleted |
| Phản ứng trực tiếp với Model | Có thể có nhiều Listener |
| Thường dùng cho Model lifecycle | Phù hợp business event |
Ví dụ:
Observer
Post
│
└── deleted
│
▼
PostObserver
Trong khi:
Event
PostPublished
│
├── LogPublishedPost
├── NotifyPostPublished
└── UpdateStatistics
Điểm quan trọng:
Observer theo dõi vòng đời Model.
Event mô tả một sự kiện/nghiệp vụ xảy ra trong ứng dụng.
Event & Listener là cơ chế giúp chúng ta tách:
"Sự kiện xảy ra"
khỏi:
"Công việc cần thực hiện khi sự kiện đó xảy ra."
Tạo Event:
php artisan make:event UserRegistered
📋 Copy
Tạo Listener:
php artisan make:listener SendWelcomeEmail --event=UserRegistered
📋 Copy
Event:
class UserRegistered
{
public function __construct(
public User $user
) {
}
}
📋 Copy
Phát Event:
event(new UserRegistered($user));
📋 Copy
Listener:
class SendWelcomeEmail
{
public function handle(UserRegistered $event): void
{
$user = $event->user;
// Xử lý công việc
}
}
📋 Copy
Mô hình cần nhớ:
Controller / Service
│
▼
Event
│
├──────────────┐
▼ ▼
Listener A Listener B
│ │
▼ ▼
Công việc Công việc
Observer: Model xảy ra Event → Observer phản ứng.
Event: Một sự kiện nghiệp vụ xảy ra → phát thông báo.
Listener: Nghe Event → thực hiện công việc.
Ví dụ:
Post
│
└── updated
│
▼
PostObserver
là cách tiếp cận của Observer.
Còn:
PostPublished
│
├── LogPublishedPost
├── NotifyPostPublished
└── UpdateStatistics
là cách tiếp cận Event & Listener.
Và khi Listener cần xử lý công việc nặng:
Event
│
▼
Listener
│
▼
Queue
│
▼
Worker
Đây chính là cầu nối để chúng ta bước sang các nội dung tiếp theo về Queue, Mail và Notification trong Laravel.
x1
quay về MỤC LỤC
Cập nhật: 2026-09-07T20:07:00.245+07:00
Trong thực tế, lập trình viên web chuyên nghiệp không “ngồi đếm vòng lặp bằng mắt”.&
Họ kiểm soát việc chạy dư / thiếu / lặp vô hạn bằng một số nguyên tắc rất rõ ràng.
Ví dụ PHP/Laravel:
foreach ($posts as $post) {
// xử lý
}Pro thường kiểm tra dữ liệu trước khi loop:
$posts = Post::latest()->get();
foreach ($posts as $post) {
...
}Ở đây:
$postslà nguồn dữ liệu duy nhất.
Nếu có 20 phần tử → vòng foreach chạy đúng 20 lần.
Không nên vừa loop vừa tự tạo thêm dữ liệu vào chính collection đang loop nếu không có lý do rõ ràng.
foreach thường an toàn hơn forVí dụ:
for ($i = 0; $i < count($posts); $i++) {
...
}Có nhiều thứ phải tự kiểm soát:
$i bắt đầu từ đâu?
$i tăng thế nào?
điều kiện dừng là gì?
count() có thay đổi không?Trong khi:
foreach ($posts as $post) {
...
}thì PHP tự đảm bảo:
phần tử 1
↓
phần tử 2
↓
...
↓
phần tử cuối
↓
STOPNếu chỉ cần duyệt từng phần tử, pro thường ưu tiên foreach.
for, phải có 3 thứMột vòng for chuẩn:
for (
$i = 0; // START
$i < count($items); // CONDITION
$i++ // PROGRESS
) {
...
}Có thể nhớ:
Start → Condition → Progress
Thiếu phần Progress là một nguồn gây loop vô hạn:
for ($i = 0; $i < 10;) {
echo $i;
}$i không bao giờ tăng → chạy mãi.
count() trước khi loopVí dụ:
if ($posts->isNotEmpty()) {
foreach ($posts as $post) {
...
}
}Hoặc PHP array:
if (count($items) > 0) {
foreach ($items as $item) {
...
}
}Không phải lúc nào cũng bắt buộc, nhưng nó giúp code thể hiện rất rõ:
Có dữ liệu → xử lý
Không có dữ liệu → không làm gì
Ví dụ muốn lấy tối đa 10 bài:
$posts = Post::latest()
->limit(10)
->get();
foreach ($posts as $post) {
...
}Ở đây database đã đảm bảo:
0 → 10 recordsThay vì lấy 10.000 record rồi:
foreach ($posts as $post) {
if ($count >= 10) {
break;
}
}Cách thứ hai vẫn làm được, nhưng thường không tối ưu.
Ví dụ:
$posts = Post::latest()->paginate(10);Laravel chỉ trả về khoảng:
page 1
10 recordsSau đó:
foreach ($posts as $post) {
...
}→ chỉ loop trên số record của trang hiện tại.
Đây là tư duy rất quan trọng:
Giới hạn dữ liệu càng sớm càng tốt.
Ví dụ:
foreach ($users as $user) {
foreach ($posts as $post) {
...
}
}Nếu:
users = 100
posts = 1,000thì phần trong cùng có thể chạy:
100 × 1,000
= 100,000 lầnĐây là lý do developer chuyên nghiệp rất để ý complexity.
Thay vì:
foreach ($users as $user) {
foreach ($posts as $post) {
...
}
}Laravel thường có thể xử lý quan hệ:
$users = User::with('posts')->get();rồi:
foreach ($users as $user) {
foreach ($user->posts as $post) {
...
}
}Hoặc thậm chí thay đổi cách query để database làm phần matching.
Trong lúc debug:
$count = 0;
foreach ($posts as $post) {
$count++;
...
}
dd($count);Kết quả:
20→ biết chính xác loop đã chạy 20 lần.
Hoặc:
foreach ($posts as $index => $post) {
dump([
'iteration' => $index,
'id' => $post->id,
]);
}Ví dụ:
iteration: 0
id: 15
iteration: 1
id: 22
iteration: 2
id: 31Cách này rất hữu ích khi bạn đang debug mấy vấn đề kiểu lâu lâu dữ liệu bị thiếu / $img='' / array có số phần tử bất thường mà bạn đang gặp.
Một developer mới thường nghĩ:
"Hình như vòng này chạy 10 lần."
Pro sẽ hỏi:
$items = ...;$items có bao nhiêu phần tử?
count($items)Sau đó:
foreach ($items as $item)Về logic:
Số iteration = số phần tử được duyệtĐó là thứ có thể đo được, không phải phỏng đoán.
Không để loop chịu trách nhiệm cho những thứ mà database có thể làm tốt hơn.
Ví dụ không nên:
$users = User::all();
foreach ($users as $user) {
if ($user->status === 'active') {
...
}
}Nếu mục tiêu chỉ là active users:
$users = User::where('status', 'active')->get();Sau đó:
foreach ($users as $user) {
...
}Database lọc trước.
Tức là:
❌ Lấy 100.000 → PHP loop → bỏ 90.000
✅ Database lọc → lấy 10.000 → PHP loopMỗi khi viết loop, tự hỏi 5 câu:
1. Tôi đang loop trên cái gì?
2. Nó có bao nhiêu phần tử?
3. Điều kiện dừng là gì?
4. Có khả năng một phần tử bị xử lý 2 lần không?
5. Có thể lọc/giới hạn dữ liệu trước khi loop không?Và nếu là for thì thêm:
6. Biến đếm có chắc chắn tiến về điều kiện STOP không? DATA
↓
FILTER / LIMIT
↓
COLLECTION
↓
foreach
↓
PROCESS
↓
STOPĐây mới là cách kiểm soát loop trong code thực tế.
Không phải cố nhớ “vòng này chạy bao nhiêu lần”, mà thiết kế để số lần chạy là hệ quả tất yếu của dữ liệu và điều kiện dừng.
Cập nhật: 2026-08-29T12:29:07.061+07:00
Trong các bài trước, chúng ta đã làm việc trực tiếp với Eloquent:
$user = User::create([
'name' => 'Nguyễn Văn A',
'email' => 'a@gmail.com',
'password' => bcrypt('123456'),
]);
Hoặc:
$user->update([
'name' => 'Trần Văn B',
]);
Nhưng sẽ có trường hợp:
Mỗi khi User được tạo, cập nhật hoặc xóa thì ứng dụng phải tự động thực hiện thêm một công việc nào đó.
Ví dụ:
User mới được tạo → ghi Log.
Post mới được tạo → tự tạo slug.
Post bị xóa → xóa luôn ảnh liên quan.
User được cập nhật → ghi lại lịch sử thay đổi.
Post được publish → thực hiện một xử lý bổ sung.
Nếu viết tất cả những logic này trong Controller, code sẽ rất nhanh trở nên khó quản lý.
Đây là lúc Observer phát huy tác dụng.
⚙️ Tạo Observer
Observer là một class dùng để theo dõi các sự kiện xảy ra trên một Eloquent Model.
Thay vì viết:
$user = User::create([
// ...
]);
// xử lý thêm
ở nhiều Controller khác nhau, chúng ta có thể tập trung các xử lý liên quan đến User vào một Observer.
Mô hình:
User Model
│
├── creating
├── created
├── updating
├── updated
├── deleting
├── deleted
└── ...
│
▼
UserObserver
│
├── Ghi Log
├── Xử lý dữ liệu
├── Xóa file
└── Các tác vụ liên quan
Laravel cho phép tạo Observer bằng Artisan và đăng ký Observer trực tiếp với Model.
Observer đặc biệt hữu ích khi một Model có nhiều hành động tự động đi kèm.
Ví dụ với Post:
Tạo Post
│
├── Tạo slug
├── Ghi log
└── Xử lý dữ liệu liên quan
Cập nhật Post
│
├── Kiểm tra thay đổi
└── Ghi log
Xóa Post
│
└── Xóa ảnh
Nếu đặt toàn bộ logic này trong PostController, Controller sẽ ngày càng lớn.
Observer giúp tách phần logic này ra:
PostController
│
│ CRUD
▼
Post Model
│
▼
PostObserver
│
├── created()
├── updated()
└── deleted()
Laravel cung cấp Artisan command:
php artisan make:observer UserObserver --model=User
Observer sẽ được tạo trong:
app/
└── Observers/
└── UserObserver.php
Laravel hỗ trợ make:observer để tạo Observer và có thể chỉ định Model bằng --model.
Sau khi tạo:
<?php
namespace App\Observers;
use App\Models\User;
class UserObserver
{
public function created(User $user): void
{
//
}
public function updated(User $user): void
{
//
}
public function deleted(User $user): void
{
//
}
public function restored(User $user): void
{
//
}
public function forceDeleted(User $user): void
{
//
}
}
Mỗi method tương ứng với một Eloquent Model Event.
Ví dụ:
public function created(User $user): void
{
//
}
method này được gọi sau khi User được tạo thành công.
Một Observer có thể theo dõi nhiều Eloquent event.
Một số event quan trọng:
| Event | Thời điểm |
|---|---|
retrieved | Model được lấy từ database |
creating | Trước khi tạo |
created | Sau khi tạo |
updating | Trước khi cập nhật |
updated | Sau khi cập nhật |
saving | Trước khi save |
saved | Sau khi save |
deleting | Trước khi xóa |
deleted | Sau khi xóa |
restoring | Trước khi restore |
restored | Sau khi restore |
forceDeleted | Sau khi force delete |
Laravel cho phép Observer lắng nghe nhiều Eloquent event khác nhau, không chỉ created, updated và deleted.
creating và createdĐây là hai event rất dễ nhầm.
creatingChạy trước khi record được lưu vào database.
public function creating(User $user): void
{
//
}
createdChạy sau khi record đã được tạo.
public function created(User $user): void
{
//
}
Có thể hình dung:
User::create()
│
▼
creating
│
▼
INSERT DATABASE
│
▼
created
Ví dụ:
public function created(User $user): void
{
logger("User {$user->id} đã được tạo");
}
Khi User được tạo:
User::create([
'name' => 'Nguyễn Văn A',
'email' => 'a@gmail.com',
'password' => bcrypt('123456'),
]);
Observer sẽ tự động chạy:
created()
updating và updatedTương tự:
public function updating(User $user): void
{
//
}
chạy trước khi cập nhật.
Còn:
public function updated(User $user): void
{
//
}
chạy sau khi cập nhật thành công.
Luồng:
$user->update(...)
│
▼
updating
│
▼
UPDATE DATABASE
│
▼
updated
Ví dụ:
public function updated(User $user): void
{
logger("User {$user->id} đã được cập nhật");
}
deleting và deletedKhi xóa Model:
$user->delete();
Laravel có thể kích hoạt:
deleting()
trước khi xóa và:
deleted()
sau khi xóa.
Ví dụ:
public function deleted(User $user): void
{
logger("User {$user->id} đã bị xóa");
}
Điều này đặc biệt hữu ích khi cần xử lý dữ liệu liên quan.
Trong bài 30 chúng ta đã học:
$user->delete();
với Soft Delete.
Khi sử dụng:
use SoftDeletes;
thì:
$user->delete();
không nhất thiết xóa record khỏi database.
Record sẽ được đánh dấu bằng:
deleted_at
Observer vẫn có thể theo dõi các event liên quan đến Model.
Ví dụ:
public function deleted(User $user): void
{
logger("User {$user->id} đã được soft delete");
}
Khi restore:
$user->restore();
có thể xử lý:
public function restored(User $user): void
{
logger("User {$user->id} đã được khôi phục");
}
ObservedByMột cách hiện đại là sử dụng attribute:
#[ObservedBy([UserObserver::class])]
Trong Model:
<?php
namespace App\Models;
use App\Observers\UserObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Foundation\Auth\User as Authenticatable;
#[ObservedBy([UserObserver::class])]
class User extends Authenticatable
{
//
}
Cách này giúp mối quan hệ giữa:
User
↓
UserObserver
được thể hiện ngay trên Model.
Laravel hỗ trợ ObservedBy để đăng ký Observer trực tiếp trên Model.
Ghi chú: khúc này trở đi đã có thể check log bằng lệnh:
tail -f storage/logs/laravel.log
Laravel Breeze tạo user bằng cách bấm nút Register.&
Mặc định Laravel LOG sẽ lấy giờ UTC nên bị sai giờ phải sửa lại múi giờ.
config/app.phpTìm:
'timezone' => 'UTC',
đổi thành:
'timezone' => 'Asia/Ho_Chi_Minh',
Sau đó chạy:
php artisan config:clear
Nếu đang cache config thì có thể chạy:
php artisan optimize:clear
Tiện tay thêm tính năng nút con mắt để show pass cho cả 2 form register và login.
thay thế chõ password và confirm password trong file resources/views/auth/register.blade.php bằng code chuẩn bên dưới:
<x-guest-layout>
<form method="POST" action="{{ route('register') }}"
x-data="{ showPass: false, showConfirmPass: false }">
@csrf
...
<!-- Password -->
<div class="mt-4">
<x-input-label for="password" :value="__('Password')" />
<div class="relative mt-1">
<input id="password"
:type="showPass ? 'text' : 'password'"
name="password"
required
autocomplete="new-password"
class="border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm block w-full pr-10" />
<button type="button"
@click="showPass = !showPass"
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 focus:outline-none">
<svg x-show="!showPass" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<svg x-show="showPass" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="display: none;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858-5.908a10.018 10.018 0 013.832-.825c4.478 0 8.268 2.943 9.542 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21f-9-9M3 3l18 18" />
</svg>
</button>
</div>
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
<!-- Confirm Password -->
<div class="mt-4">
<x-input-label for="password_confirmation" :value="__('Confirm Password')" />
<div class="relative mt-1">
<input id="password_confirmation"
:type="showConfirmPass ? 'text' : 'password'"
name="password_confirmation"
required
autocomplete="new-password"
class="border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm block w-full pr-10" />
<button type="button"
@click="showConfirmPass = !showConfirmPass"
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 focus:outline-none">
<svg x-show="!showConfirmPass" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<svg x-show="showConfirmPass" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="display: none;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858-5.908a10.018 10.018 0 013.832-.825c4.478 0 8.268 2.943 9.542 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21f-9-9M3 3l18 18" />
</svg>
</button>
</div>
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
</div>
...
</form>
</x-guest-layout>
observe()Một cách khác là đăng ký bằng:
User::observe(UserObserver::class);
Ví dụ trong AppServiceProvider:
<?php
namespace App\Providers;
use App\Models\User;
use App\Observers\UserObserver;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
User::observe(UserObserver::class);
}
}
Laravel cũng hỗ trợ cách đăng ký Observer thông qua phương thức observe().
Trong Laravel hiện đại, có thể dùng:
#[ObservedBy([UserObserver::class])]
hoặc:
User::observe(UserObserver::class);
Với project học tập và Blog CMS của chúng ta, ObservedBy khá dễ hiểu vì Model thể hiện trực tiếp Observer của nó.
Ví dụ:
app/
├── Models/
│ └── User.php
│
└── Observers/
└── UserObserver.php
Trong User.php:
#[ObservedBy([UserObserver::class])]
class User extends Authenticatable
{
//
}
Trong Blog CMS của chúng ta có Model:Post
Tạo Observer:
php artisan make:observer PostObserver --model=Post
Sau đó:
app/
├── Models/
│ └── Post.php
│
└── Observers/
└── PostObserver.php
Observer:
<?php
namespace App\Observers;
use App\Models\Post;
class PostObserver
{
public function created(Post $post): void
{
logger("Post {$post->id} đã được tạo");
}
public function updated(Post $post): void
{
logger("Post {$post->id} đã được cập nhật");
}
public function deleted(Post $post): void
{
logger("Post {$post->id} đã bị xóa");
}
}
Đăng ký:
<?php
namespace App\Models;
use App\Observers\PostObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Model;
#[ObservedBy([PostObserver::class])]
class Post extends Model
{
//
}
Bây giờ Controller chỉ cần:
$post = Post::create([
'title' => 'Laravel Observer',
'content' => 'Nội dung bài viết',
]);
Không cần gọi:
$postObserver->created($post);
Laravel tự xử lý Observer.
Không dùng Observer:
public function store(Request $request)
{
$post = Post::create($request->validated());
logger("Post {$post->id} đã được tạo");
// xử lý thêm...
return redirect()->route('posts.index');
}
Controller phải biết rất nhiều việc.
Có Observer:
public function store(Request $request)
{
$post = Post::create($request->validated());
return redirect()->route('posts.index');
}
Còn:
public function created(Post $post): void
{
logger("Post {$post->id} đã được tạo");
}
được đưa vào:
PostObserver
Kết quả:
Controller
│
└── Xử lý Request + CRUD
Observer
│
└── Xử lý các phản ứng sau Model Event
Đây chính là một trong những lợi ích lớn nhất của Observer.
Hai thành phần này có vai trò khác nhau.
Nhận HTTP Request:
Browser
↓
Route
↓
Controller
↓
Model
Phản ứng với sự kiện của Model:
Model
↓
Eloquent Event
↓
Observer
Ví dụ:
POST /posts
│
▼
PostController
│
▼
Post::create()
│
▼
Post created event
│
▼
PostObserver::created()
Observer hoạt động với các thao tác Eloquent Model thông thường.
Ví dụ:
$post = Post::find(1);
$post->update([
'title' => 'Tiêu đề mới',
]);
Observer của Model có thể nhận event tương ứng.
Nhưng khi thực hiện các thao tác query hàng loạt như:
Post::where('status', 'draft')
->update([
'status' => 'published',
]);
không nên mặc định nghĩ rằng Observer sẽ nhận được từng Model như khi bạn gọi:
$post->update();
Lý do là thao tác mass update trực tiếp trên query không hydrate từng Model.
Vì vậy, khi cần Observer xử lý từng record, cần thiết kế thao tác dữ liệu phù hợp.
Trong những ứng dụng thực tế, Observer đôi khi thực hiện công việc liên quan đến database.
Ví dụ:
Tạo Order
│
├── Order Observer
│
└── Tạo dữ liệu liên quan
Nếu ứng dụng sử dụng database transaction, có trường hợp chúng ta muốn Observer chỉ thực hiện handler sau khi transaction commit thành công.
Laravel hỗ trợ ShouldHandleEventsAfterCommit cho Observer.
Ví dụ:
<?php
namespace App\Observers;
use App\Models\User;
use Illuminate\Contracts\Events\ShouldHandleEventsAfterCommit;
class UserObserver implements ShouldHandleEventsAfterCommit
{
public function created(User $user): void
{
//
}
}
Khi đó, nếu đang nằm trong transaction, event handler sẽ được xử lý sau khi transaction commit. Nếu không có transaction, handler thực hiện bình thường.
Đây là phần nâng cao; khi mới học Observer, chỉ cần hiểu nguyên tắc này là đủ.
Observer phù hợp khi:
Model Event
│
▼
Một hành động tự động
Ví dụ:
created
↓
Ghi log
created
↓
Xử lý dữ liệu liên quan
deleted
↓
Xử lý file ảnh
updated
↓
Ghi lịch sử thay đổi
Observer đặc biệt hữu ích khi logic này có thể xảy ra từ nhiều nơi khác nhau trong ứng dụng.
Không nên biến Observer thành nơi chứa toàn bộ business logic của ứng dụng.
Ví dụ không nên có một Observer khổng lồ:
class PostObserver
{
public function created(Post $post)
{
// 200 dòng code...
}
public function updated(Post $post)
{
// 300 dòng code...
}
public function deleted(Post $post)
{
// 250 dòng code...
}
}
Khi logic quá phức tạp, nên tách sang:
Service
Job
Event / Listener
Đặc biệt, các tác vụ nặng như gửi email, xử lý ảnh lớn hoặc gọi API bên ngoài thường không nên nhét trực tiếp vào Observer.
Những nội dung này sẽ liên quan đến các bài sau trong khóa học:
Bài 34 — Event & Listener
Bài 35 — Queue
Bài 36 — Mail
Bài 37 — Notification
Với Blog CMS mà chúng ta đang xây dựng, có thể tổ chức:
Post
│
└── PostObserver
│
├── created()
├── updated()
├── deleted()
└── restored()
Ví dụ:
Tạo Post
↓
PostObserver
↓
Ghi Log
Xóa Post:
Xóa Post
↓
PostObserver
↓
Xử lý dữ liệu/file liên quan
Restore Post:
Restore Post
↓
PostObserver
↓
Xử lý sau khi khôi phục
Như vậy Controller không cần biết toàn bộ những việc phụ xảy ra sau mỗi thao tác.
Observer là cơ chế giúp chúng ta theo dõi các Eloquent Model Event và đưa những xử lý tự động ra khỏi Controller.
Tạo Observer:
php artisan make:observer UserObserver --model=User
Các event thường gặp:
creating
created
updating
updated
deleting
deleted
restoring
restored
forceDeleted
Đăng ký bằng Attribute:
use App\Observers\UserObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
#[ObservedBy([UserObserver::class])]
class User extends Authenticatable
{
//
}
Hoặc:
User::observe(UserObserver::class);
Mô hình cần nhớ:
Controller
│
▼
Eloquent Model
│
▼
Model Event
│
▼
Observer
│
▼
Xử lý tự động
Observer không thay thế Controller.
Observer giúp chúng ta tách những hành động cần tự động thực hiện khi Model được tạo, cập nhật, xóa hoặc khôi phục.
Đây là bước quan trọng để Blog CMS bắt đầu có kiến trúc sạch hơn trước khi chuyển sang Bài 34 — Event & Listener.
Controller thực hiện hành động.
Model lưu trữ và thao tác dữ liệu.
Observer phản ứng khi Model xảy ra sự kiện.
Laravel 12 hỗ trợ cả cách đăng ký Observer bằng ObservedBy và observe().
x1
quay về MỤC LỤC
Cập nhật: 2026-09-06T15:50:46.803+07:00
Trong các bài trước, chúng ta thường viết Query trực tiếp trong Controller:
$users = User::where('is_active', true)
->where('role', 'user')
->latest()
->paginate(10);
Cách này hoàn toàn đúng.
Nhưng khi dự án lớn lên, những điều kiện truy vấn giống nhau sẽ xuất hiện ở rất nhiều Controller.
Ví dụ:
User::where('is_active', true)->get();
User::where('is_active', true)->latest()->get();
User::where('is_active', true)->paginate(10);
Lúc này chúng ta có thể đưa các điều kiện truy vấn thường dùng vào Scope.
Laravel 13 hỗ trợ hai loại chính:
Local Scope
Global Scope
Local Scope chỉ được sử dụng khi chúng ta chủ động gọi nó.
Global Scope tự động được áp dụng vào các query của Model. Laravel cũng sử dụng cơ chế Global Scope cho Soft Delete.
#[Scope]?Có thể hiểu đơn giản:
Scope
│
├── Local Scope
│ ↓
│ Chủ động gọi
│
└── Global Scope
↓
Tự động áp dụng
Scope giúp chúng ta đóng gói những điều kiện truy vấn thường xuyên sử dụng vào Model.
Thay vì viết:
User::where('is_active', true)->get();
nhiều lần, chúng ta có thể tạo:
User::active()->get();
Code sẽ ngắn và dễ đọc hơn.
Local Scope là một nhóm điều kiện Query được định nghĩa trong Model và chỉ được áp dụng khi chúng ta gọi Scope đó.
Ví dụ:
User::active()->get();
Ở đây:
active()
↓
Local Scope
↓
where('is_active', true)
Nếu không gọi:
User::active()
thì điều kiện đó không được áp dụng.
Laravel 13 định nghĩa Local Scope bằng attribute #[Scope].
Mở:
app/Models/User.php
Thêm:
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
Sau đó tạo Scope:
#[Scope]
protected function active(Builder $query): void
{
$query->where('is_active', true);
}
Bây giờ có thể sử dụng:
$users = User::active()->get();
Laravel sẽ thêm điều kiện:
where is_active = 1
vào Query.
#[Scope]?Đây là điểm quan trọng khi học Laravel 13.
Cách hiện đại:
#[Scope]
protected function active(Builder $query): void
{
$query->where('is_active', true);
}
Laravel 13 sử dụng Illuminate\Database\Eloquent\Attributes\Scope để đánh dấu method là Local Scope.
Trong các phiên bản Laravel cũ, chúng ta thường gặp cú pháp:
public function scopeActive($query)
{
return $query->where('is_active', true);
}
Cú pháp cũ có thể xuất hiện trong nhiều tutorial trên Internet, nhưng trong khóa học Laravel 13 này, chúng ta ưu tiên cú pháp hiện đại.
Sau khi có:
#[Scope]
protected function active(Builder $query): void
{
$query->where('is_active', true);
}
Controller có thể viết:
$users = User::active()->get();
Thay vì:
$users = User::where('is_active', true)->get();
Hai cách cho cùng mục đích.
Nhưng Scope có ưu điểm là điều kiện:
where('is_active', true)
được đặt trong Model và có thể tái sử dụng.
Ví dụ chúng ta thường xuyên cần User:
role = user
is_active = true
Có thể tạo:
#[Scope]
protected function normalUsers(Builder $query): void
{
$query
->where('role', 'user')
->where('is_active', true);
}
Sau đó:
$users = User::normalUsers()->get();
Controller không cần biết chi tiết điều kiện bên trong.
Đây là điểm rất mạnh của Local Scope.
Tạo:
#[Scope]
protected function active(Builder $query): void
{
$query->where('is_active', true);
}
và:
#[Scope]
protected function admin(Builder $query): void
{
$query->where('role', 'admin');
}
Có thể viết:
$users = User::active()
->admin()
->get();
Laravel sẽ kết hợp các điều kiện.
Có thể hình dung:
User
│
├── active()
│ ↓
│ is_active = 1
│
└── admin()
↓
role = admin
Kết quả tương đương:
where is_active = 1
and role = 'admin'
Laravel cho phép chain nhiều Local Scope với nhau.
Trong dự án Blog CMS của khóa học, chúng ta có:
users
────────────────────────────
id
name
email
role
is_active
created_at
updated_at
Chúng ta có thể tạo các Scope:
active()
inactive()
admin()
editor()
user()
Sau đó Controller rất dễ đọc.
Ví dụ:
$users = User::active()
->latest()
->paginate(10);
Hoặc:
$users = User::admin()
->latest()
->paginate(10);
Hoặc:
$users = User::active()
->admin()
->latest()
->paginate(10);
Không phải Scope nào cũng cố định.
Ví dụ chúng ta muốn tìm User theo Role.
Thay vì tạo:
admin()
editor()
user()
chúng ta có thể tạo một Scope nhận tham số.
#[Scope]
protected function role(Builder $query, string $role): void
{
$query->where('role', $role);
}
Sử dụng:
User::role('admin')->get();
hoặc:
User::role('editor')->get();
hoặc:
User::role('user')->get();
Laravel gọi đây là Dynamic Scopes. Các tham số của Scope được khai báo sau $query.
Ví dụ:
#[Scope]
protected function role(Builder $query, string $role): void
{
$query->where('role', $role);
}
Trong Controller:
$users = User::role($request->role)
->latest()
->paginate(10);
Chúng ta có thể xây dựng:
Dashboard
│
▼
Filter Role
│
├── admin
├── editor
└── user
│
▼
User::role()
│
▼
Database
Đây là cách rất phù hợp cho chức năng Filter Users.
Không chỉ User.
Ví dụ Model Post.
Bảng:
posts
────────────────────────
id
title
content
status
category_id
user_id
created_at
updated_at
Chúng ta thường xuyên cần:
published
draft
hidden
Có thể tạo:
#[Scope]
protected function published(Builder $query): void
{
$query->where('status', 'published');
}
và:
#[Scope]
protected function draft(Builder $query): void
{
$query->where('status', 'draft');
}
Sau đó:
Post::published()->latest()->get();
hoặc:
Post::draft()->latest()->get();
Chú ý: nếu dùng Datatables để phân trang thì không dùng được.
Trang chủ Blog chỉ cần bài viết đã Published.
Thay vì:
$posts = Post::where('status', 'published')
->latest()
->paginate(10);
Có thể viết:
$posts = Post::published()
->latest()
->paginate(10);
Nhìn vào Controller, chúng ta lập tức hiểu:
Lấy những bài viết đã xuất bản.
Ví dụ chúng ta có:
categories
────────────────────
id
name
is_active
Tạo:
#[Scope]
protected function active(Builder $query): void
{
$query->where('is_active', true);
}
Sau đó:
$categories = Category::active()->get();
Rất dễ đọc.
Không có Scope:
$posts = Post::where('status', 'published')
->whereNotNull('category_id')
->latest()
->paginate(10);
Có Scope:
$posts = Post::published()
->withCategory()
->latest()
->paginate(10);
Controller trở thành nơi điều phối nghiệp vụ, thay vì chứa quá nhiều điều kiện Query.
Khác với Local Scope.
Local Scope:
Chỉ chạy khi gọi
Global Scope:
Tự động chạy
Ví dụ:
User::all()
bình thường lấy tất cả User.
Nếu User có Global Scope:
User::all()
│
▼
Global Scope
│
▼
Tự động thêm điều kiện
Laravel sẽ tự động áp dụng điều kiện đó vào Query.
Giả sử chúng ta muốn hệ thống luôn luôn chỉ lấy User đang hoạt động.
Điều kiện:
where('is_active', true)
Nếu dùng Local Scope:
User::active()->get();
thì chúng ta phải nhớ gọi:
active()
Nhưng nếu dùng Global Scope:
User::all();
Laravel tự động thêm:
is_active = true
Đây chính là khác biệt quan trọng nhất.
Laravel 13 có thể tạo Scope class bằng Artisan:
php artisan make:scope ActiveUserScope
Laravel sẽ tạo Scope trong:
app/Models/Scopes
Đây là vị trí mặc định được Laravel sử dụng khi tạo Global Scope bằng Artisan.
File:
app/Models/Scopes/ActiveUserScope.php
Nội dung:
<?php
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class ActiveUserScope implements Scope
{
public function apply(
Builder $builder,
Model $model
): void {
$builder->where('is_active', true);
}
}
Scope class cần triển khai:
Illuminate\Database\Eloquent\Scope
và phương thức:
apply()
sẽ thêm điều kiện vào Query.
Laravel 13 hỗ trợ attribute #[ScopedBy].
Trong:
app/Models/User.php
thêm:
use App\Models\Scopes\ActiveUserScope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
Sau đó:
#[ScopedBy([ActiveUserScope::class])]
class User extends Authenticatable
{
//
}
Bây giờ:
User::all();
sẽ tự động áp dụng:
is_active = true
Laravel 13 hỗ trợ ScopedBy để khai báo Global Scope trực tiếp trên Model.
Giả sử database:
users
────────────────────────────
id | name | is_active
────────────────────────────
1 | A | 1
2 | B | 0
3 | C | 1
4 | D | 0
Khi:
User::all();
Global Scope sẽ tự động thêm:
is_active = true
Kết quả:
1 | A | 1
3 | C | 1
User:
2 | B | 0
4 | D | 0
không xuất hiện.
Đây là ví dụ rất dễ hiểu vì chúng ta vừa học Soft Delete ở Bài 30.
Khi Model sử dụng:
use SoftDeletes;
Laravel không đơn giản chỉ thay đổi cách delete() hoạt động.
Laravel còn sử dụng Global Scope để mặc định loại các record đã Soft Delete khỏi kết quả Query.
Ví dụ:
users
────────────────────────────
id | name | deleted_at
────────────────────────────
1 | A | NULL
2 | B | 2026-08-20
3 | C | NULL
Khi:
User::all();
kết quả mặc định:
A
C
User B không xuất hiện.
Đôi khi chúng ta cần lấy cả dữ liệu bị Global Scope loại bỏ.
Laravel cung cấp:
withoutGlobalScope()
Ví dụ:
User::withoutGlobalScope(
ActiveUserScope::class
)->get();
Lúc này Global Scope:
ActiveUserScope
sẽ không được áp dụng cho Query đó.
Nếu muốn bỏ toàn bộ Global Scope:
User::withoutGlobalScopes()->get();
Có thể hiểu:
User::all()
│
▼
Global Scopes
│
▼
Áp dụng
User::withoutGlobalScopes()
│
▼
Bỏ Global Scopes
Laravel 13 cũng hỗ trợ withoutGlobalScopes([...]) để chỉ bỏ một số Scope cụ thể.
Nếu Scope rất đơn giản, không nhất thiết phải tạo một class riêng.
Có thể khai báo trực tiếp trong Model:
protected static function booted(): void
{
static::addGlobalScope('active', function (Builder $builder) {
$builder->where('is_active', true);
});
}
Khi đó:
User::all();
vẫn tự động thêm:
is_active = true
Laravel hỗ trợ Anonymous Global Scope thông qua Closure.
Vì chúng ta đặt tên:
active
nên có thể bỏ Scope bằng:
User::withoutGlobalScope('active')->get();
Tên:
active
phải giống tên đã đăng ký trong:
addGlobalScope()
| Đặc điểm | Local Scope | Global Scope |
|---|---|---|
| Kích hoạt | Chủ động gọi | Tự động |
| Phạm vi | Query cụ thể | Mọi Query của Model |
| Mục đích | Tái sử dụng điều kiện | Luôn áp dụng một quy tắc |
| Có thể bỏ | Không cần bỏ | Có thể withoutGlobalScope() |
| Ví dụ | active() | Soft Delete |
Có thể nhớ:
LOCAL
↓
Tôi muốn thì tôi gọi
GLOBAL
↓
Laravel tự động gọi
Local Scope phù hợp khi điều kiện:
Được sử dụng nhiều lần.
Không phải Query nào cũng cần.
Có ý nghĩa rõ ràng.
Giúp Controller dễ đọc.
Ví dụ:
active()
published()
draft()
admin()
editor()
popular()
recent()
role()
Đây là trường hợp sử dụng phổ biến nhất.
Global Scope phù hợp với những quy tắc mà Model gần như luôn luôn phải tuân thủ.
Ví dụ:
Chỉ lấy dữ liệu active
Chỉ lấy dữ liệu thuộc tenant hiện tại
Chỉ lấy dữ liệu của một cửa hàng
Soft Delete
Nhưng cần cẩn thận.
Global Scope có thể khiến Developer mới khó hiểu:
User::all();
nhưng database không trả về tất cả record.
Lý do là phía sau Query đang có Global Scope.
Đây là nguyên tắc rất quan trọng.
Nếu chúng ta tạo quá nhiều Global Scope:
User
├── ActiveScope
├── RoleScope
├── CountryScope
├── DepartmentScope
├── PermissionScope
└── ...
một Query đơn giản:
User::all();
có thể trở nên rất khó đoán.
Vì vậy:
Local Scope nên là lựa chọn mặc định khi chỉ muốn tái sử dụng Query.
Global Scope chỉ nên dùng khi quy tắc đó thực sự cần áp dụng mặc định.
Với dự án chúng ta đang xây dựng, có thể tổ chức như sau:
User
├── active()
├── role()
└── admin()
Post
├── published()
├── draft()
├── hidden()
└── category()
Category
└── active()
Ví dụ trang quản trị:
$users = User::active()
->latest()
->paginate(10);
Trang Blog:
$posts = Post::published()
->latest()
->paginate(10);
Trang Draft:
$posts = Post::draft()
->latest()
->paginate(10);
Code rất dễ đọc.
Ví dụ trang Users có:
Search
Role
Status
Pagination
Chúng ta có thể xây dựng:
$users = User::query()
->active()
->role($request->role)
->latest()
->paginate(10);
Nếu cần thêm Search:
$users = User::query()
->active()
->role($request->role)
->search($request->search)
->latest()
->paginate(10);
Khi đó Model trở thành nơi chứa những Query có thể tái sử dụng.
So sánh:
$posts = Post::where('status', 'published')
->whereNotNull('category_id')
->where('is_active', true)
->orderByDesc('created_at')
->paginate(10);
với:
$posts = Post::published()
->withCategory()
->active()
->latest()
->paginate(10);
Cách thứ hai gần giống ngôn ngữ tự nhiên hơn:
Lấy Post
→ đã Published
→ có Category
→ đang Active
→ mới nhất
→ phân trang
Đó chính là giá trị lớn của Scope.
Ví dụ:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
#[Scope]
protected function active(Builder $query): void
{
$query->where('is_active', true);
}
#[Scope]
protected function role(
Builder $query,
string $role
): void {
$query->where('role', $role);
}
#[Scope]
protected function admin(Builder $query): void
{
$query->where('role', 'admin');
}
}
Controller:
$users = User::active()
->role('editor')
->latest()
->paginate(10);
Hoặc:
$admins = User::active()
->admin()
->latest()
->paginate(10);
Scope chỉ là cách đóng gói Query thường dùng.
Chúng ta vẫn có thể sử dụng:
where()
orderBy()
latest()
with()
paginate()
Ví dụ:
Post::published()
->where('category_id', 5)
->with('user')
->latest()
->paginate(10);
Scope và Query Builder có thể kết hợp với nhau.
Một nguyên tắc tốt:
MODEL
↓
Quy tắc truy vấn có thể tái sử dụng
CONTROLLER
↓
Điều phối Request → Query → View
Không nên biến Controller thành một nơi chứa hàng trăm dòng:
where()
where()
where()
where()
where()
nếu những điều kiện đó được sử dụng lặp lại nhiều lần.
Đưa chúng vào Scope sẽ giúp source dễ bảo trì hơn.
Local Scope:
#[Scope]
protected function active(Builder $query): void
{
$query->where('is_active', true);
}
Sử dụng:
User::active()->get();
Dynamic Scope:
#[Scope]
protected function role(
Builder $query,
string $role
): void {
$query->where('role', $role);
}
Sử dụng:
User::role('admin')->get();
Tạo:
php artisan make:scope ActiveUserScope
Scope:
class ActiveUserScope implements Scope
{
public function apply(
Builder $builder,
Model $model
): void {
$builder->where('is_active', true);
}
}
Gắn vào Model (ngoài class):
#[ScopedBy([ActiveUserScope::class])]
class User extends Authenticatable
{
//
}
Bây giờ:
User::all();
tự động áp dụng Scope.
Muốn bỏ:
User::withoutGlobalScope(
ActiveUserScope::class
)->get();
Tạo Local Scope:
active()
để lấy User:
is_active = 1
Sau đó sử dụng:
User::active()->get();
Tạo Dynamic Scope:
role()
Sử dụng:
User::role('admin')->get();
và:
User::role('editor')->get();
Trong Post Model tạo:
published()
để lấy:
status = published
Sau đó:
Post::published()
->latest()
->paginate(10);
Tạo:
draft()
để lấy:
status = draft
Sử dụng:
Post::draft()->get();
Viết Query:
User
→ active
→ role = editor
→ mới nhất
→ paginate 10
Kết quả mong muốn:
User::active()
->role('editor')
->latest()
->paginate(10);
SCOPE
│
├── LOCAL SCOPE
│ │
│ ├── #[Scope]
│ ├── Chủ động gọi
│ ├── Có thể chain
│ └── Có thể nhận tham số
│
└── GLOBAL SCOPE
│
├── Tự động áp dụng
├── #[ScopedBy]
├── Có thể dùng Scope class
└── Có thể bỏ bằng withoutGlobalScope()
Cách nhớ đơn giản nhất:
Local Scope
→ "Tôi gọi thì nó chạy."
Global Scope
→ "Nó tự chạy."
Accessor
→ "Đọc dữ liệu thì xử lý."
Mutator
→ "Ghi dữ liệu thì xử lý."
Scope
→ "Query thường dùng thì đóng gói."
Sau Bài 32, chúng ta đã có thể đưa những điều kiện Query lặp lại ra khỏi Controller và tổ chức chúng ngay trong Eloquent Model.
Đây là nền tảng rất quan trọng trước khi bước sang:
Ở bài tiếp theo, chúng ta sẽ tìm hiểu cách Laravel tự động thực hiện một hành động khi Model xảy ra các sự kiện như:
creating
created
updating
updated
deleting
deleted
restored
và xây dựng UserObserver / PostObserver cho dự án Blog CMS.
x1
quay về MỤC LỤC
Cập nhật: 2026-09-04T16:12:47.236+07:00
Trong các bài CRUD trước, chúng ta đã làm việc trực tiếp với dữ liệu:
$user->name
$user->email
$user->role
Nhưng trong dự án thực tế, đôi khi chúng ta muốn biến đổi dữ liệu trước khi hiển thị hoặc xử lý dữ liệu trước khi lưu vào database.
Ví dụ:
Database lưu nguyen van a
Website muốn hiển thị Nguyen Van A
Hoặc:
Người dùng nhập NGUYEN VAN A
Database chỉ lưu nguyen van a
Đây chính là lúc Accessor và Mutator phát huy tác dụng.
Laravel 13 sử dụng cách khai báo hiện đại thông qua Illuminate\Database\Eloquent\Casts\Attribute.
Có thể hiểu đơn giản:
ELOQUENT MODEL
│
┌────────────┴────────────┐
│ │
ACCESSOR MUTATOR
│ │
Database → PHP PHP → Database
│ │
Khi đọc dữ liệu Khi ghi dữ liệu
│ │
▼ ▼
Biến đổi để hiển thị Biến đổi trước khi lưu
Accessor dùng để thay đổi giá trị khi chúng ta lấy dữ liệu ra.
Database
↓
Accessor
↓
PHP / Blade
Mutator dùng để thay đổi giá trị khi chúng ta gán dữ liệu vào Model.
PHP / Form
↓
Mutator
↓
Database
Giả sử bảng users có:
id | name
1 | nguyen van a
2 | tran van b
3 | le van c
Khi hiển thị trên website, chúng ta muốn:
Nguyen Van A
Tran Van B
Le Van C
Thay vì phải viết:
ucwords($user->name)
ở rất nhiều nơi:
{{ ucwords($user->name) }}
Chúng ta có thể xử lý ngay trong Model bằng Accessor.
Khi đó Blade chỉ cần:
{{ $user->name }}
Eloquent sẽ tự động xử lý giá trị khi đọc thuộc tính.
Trong Laravel hiện đại, chúng ta sử dụng:
use Illuminate\Database\Eloquent\Casts\Attribute;
Sau đó định nghĩa một method trong Model:
protected function name(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucwords($value),
);
}
Đây là cách khai báo Accessor hiện đại được Laravel hỗ trợ.
Mở:
app/Models/User.php
Ví dụ:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
protected function name(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucwords($value),
);
}
}
Bây giờ database có:
nguyen van a
Nhưng:
$user->name
sẽ trả về:
Nguyen Van A
Trong Blade:
{{ $user->name }}
cũng sẽ hiển thị:
Nguyen Van A
Khi chúng ta viết:
$user->name
Eloquent nhìn thấy Accessor:
protected function name(): Attribute
và thực hiện phần:
get: fn (string $value) => ucwords($value)
Có thể hình dung:
Database
────────────────────
name = "nguyen van a"
│
▼
Accessor
│
▼
ucwords()
│
▼
"Nguyen Van A"
Điều quan trọng:
Accessor không thay đổi dữ liệu trong database.
Database vẫn là:
nguyen van a
Chỉ khi lấy dữ liệu ra thì nó mới được biến đổi.
Mutator hoạt động theo chiều ngược lại.
Ví dụ người dùng nhập:
NGUYEN VAN A
Chúng ta muốn database lưu:
nguyen van a
Có thể sử dụng:
protected function name(): Attribute
{
return Attribute::make(
set: fn (string $value) => strtolower(trim($value)),
);
}
Khi thực hiện:
$user->name = ' NGUYEN VAN A ';
Mutator sẽ xử lý:
" NGUYEN VAN A "
│
▼
trim()
│
▼
"NGUYEN VAN A"
│
▼
strtolower()
│
▼
"nguyen van a"
│
▼
Database
Laravel gọi Mutator khi giá trị thuộc tính được gán.
Đây mới là cách sử dụng rất hay.
Chúng ta có thể vừa xử lý khi ghi vừa xử lý khi đọc.
protected function name(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucwords($value),
set: fn (string $value) => strtolower(trim($value)),
);
}
Khi lưu:
$user->name = ' NGUYEN VAN A ';
Database:
nguyen van a
Khi đọc:
$user->name
Kết quả:
Nguyen Van A
Như vậy:
User nhập dữ liệu
│
▼
MUTATOR
│
▼
Database
"nguyen van a"
│
▼
ACCESSOR
│
▼
"Nguyen Van A"
│
▼
Website
Một trường hợp rất thực tế là email.
Người dùng có thể nhập:
ADMIN@GMAIL.COM
Chúng ta muốn database lưu:
admin@gmail.com
Có thể viết:
protected function email(): Attribute
{
return Attribute::make(
set: fn (string $value) => strtolower(trim($value)),
);
}
Khi:
$user->email = ' ADMIN@GMAIL.COM ';
Database sẽ nhận:
admin@gmail.com
Điều này giúp dữ liệu trong hệ thống thống nhất hơn.
Accessor không nhất thiết phải xử lý một column có sẵn.
Chúng ta có thể tạo ra một thuộc tính ảo.
Ví dụ User có:
first_name
last_name
Database:
first_name = Nguyễn
last_name = Văn A
Chúng ta muốn:
$user->full_name
trả về:
Nguyễn Văn A
Có thể viết:
protected function fullName(): Attribute
{
return Attribute::make(
get: fn ($value, array $attributes) =>
$attributes['first_name'] . ' ' . $attributes['last_name'],
);
}
Sau đó:
{{ $user->full_name }}
Laravel sẽ trả về:
Nguyễn Văn A
full_name không cần tồn tại trong database.
$attributes?Trong Accessor:
get: fn ($value, array $attributes) => ...
Laravel cung cấp:
$attributes
chứa các giá trị gốc của Model.
Ví dụ:
$attributes['first_name']
$attributes['last_name']
Do đó chúng ta có thể tạo dữ liệu mới từ nhiều column.
Ví dụ:
protected function fullName(): Attribute
{
return Attribute::make(
get: fn ($value, array $attributes) =>
trim(
$attributes['first_name'] . ' ' .
$attributes['last_name']
),
);
}
Sau khi định nghĩa:
protected function fullName(): Attribute
{
return Attribute::make(
get: fn ($value, array $attributes) =>
$attributes['first_name'] . ' ' .
$attributes['last_name'],
);
}
Trong Blade:
<h2>{{ $user->full_name }}</h2>
Không cần:
<h2>
{{ $user->first_name }}
{{ $user->last_name }}
</h2>
Model sẽ chịu trách nhiệm xử lý dữ liệu.
Ví dụ:
protected function fullName(): Attribute
{
return Attribute::make(
get: fn ($value, array $attributes) =>
$attributes['first_name'] . ' ' .
$attributes['last_name'],
);
}
Chúng ta có:
$user->full_name
nhưng bảng users không nhất thiết phải có:
full_name
Đây là computed attribute.
Database
──────────────────────
first_name
last_name
│
▼
Accessor
│
▼
full_name
Rất hữu ích khi xây dựng API hoặc giao diện.
Mặc định, một Accessor tính toán không nhất thiết xuất hiện trong kết quả toArray() hoặc JSON.
Ví dụ:
return $user->toArray();
Nếu muốn thêm thuộc tính:
full_name
vào dữ liệu JSON/array, Laravel 13 hỗ trợ khai báo #[Appends(...)].
Ví dụ:
use Illuminate\Database\Eloquent\Attributes\Appends;
#[Appends(['full_name'])]
class User extends Authenticatable
{
protected function fullName(): Attribute
{
return Attribute::make(
get: fn ($value, array $attributes) =>
$attributes['first_name'] . ' ' .
$attributes['last_name'],
);
}
}
Khi chuyển Model sang JSON, full_name sẽ được thêm vào.
Giả sử users có:
name
email
role
is_active
Chúng ta có thể tạo Accessor cho trạng thái:
protected function statusText(): Attribute
{
return Attribute::make(
get: fn ($value, array $attributes) =>
$attributes['is_active']
? 'Đang hoạt động'
: 'Đã khóa',
);
}
Trong Blade:
{{ $user->status_text }}
Nếu:
is_active = 1
sẽ hiển thị:
Đang hoạt động
Nếu:
is_active = 0
sẽ hiển thị:
Đã khóa
Chúng ta cũng có thể biến:
admin
user
editor
thành:
Quản trị viên
Người dùng
Biên tập viên
Ví dụ:
protected function roleText(): Attribute
{
return Attribute::make(
get: fn ($value, array $attributes) => match ($attributes['role']) {
'admin' => 'Quản trị viên',
'editor' => 'Biên tập viên',
'user' => 'Người dùng',
default => 'Không xác định',
},
);
}
Blade:
{{ $user->role_text }}
Kết quả:
admin → Quản trị viên
editor → Biên tập viên
user → Người dùng
$casts như thế nào?Đây là điểm rất quan trọng.
Laravel có một cơ chế khác là Attribute Casting.
Ví dụ:
protected function casts(): array
{
return [
'is_active' => 'boolean',
];
}
Khi đó:
$user->is_active
sẽ được chuyển thành kiểu:
true
hoặc:
false
Laravel cung cấp rất nhiều kiểu cast như:
boolean
integer
string
array
json
date
datetime
decimal
encrypted
hashed
enum
và nhiều kiểu khác.
Dùng Accessor khi cần:
Database
↓
Biến đổi
↓
Hiển thị / sử dụng
Ví dụ:
name
full_name
role_text
status_text
price_formatted
avatar_url
Ví dụ:
$user->status_text
hoặc:
$post->formatted_price
Dùng Mutator khi muốn chuẩn hóa dữ liệu trước khi lưu.
Ví dụ:
trim()
strtolower()
strtoupper()
chuẩn hóa dữ liệu
Ví dụ:
protected function email(): Attribute
{
return Attribute::make(
set: fn (string $value) => strtolower(trim($value)),
);
}
Khi người dùng nhập:
ADMIN@GMAIL.COM
Database lưu:
admin@gmail.com
Trong CRUD Users ở Bài 28, Controller có thể vẫn rất đơn giản:
$user->update($request->validated());
Không cần xử lý:
trim()
strtolower()
ucwords()
trong Controller nếu những quy tắc đó thuộc về Model.
Ví dụ:
$user->email = $request->email;
Mutator tự xử lý:
trim()
strtolower()
Sau đó:
$user->save();
Điều này giúp Controller sạch hơn.
Accessor rất tiện nhưng không nên lạm dụng.
Ví dụ không nên đưa những logic quá nặng:
protected function something(): Attribute
{
return Attribute::make(
get: fn () => DB::table(...)->get(...),
);
}
Nếu Accessor thực hiện query database phức tạp, khi hiển thị danh sách hàng trăm User có thể gây ra vấn đề hiệu năng.
Accessor nên ưu tiên:
Xử lý đơn giản
Tính toán đơn giản
Format dữ liệu
Tạo giá trị hiển thị
Trong Laravel hiện đại, chúng ta ưu tiên:
protected function name(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucwords($value),
set: fn (string $value) => strtolower(trim($value)),
);
}
Thay vì kiểu cú pháp cũ:
public function getNameAttribute($value)
{
return ucwords($value);
}
public function setNameAttribute($value)
{
$this->attributes['name'] = strtolower(trim($value));
}
Cú pháp Attribute::make() rõ ràng hơn và phù hợp với Laravel hiện đại.
Một Model có thể viết:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
protected $fillable = [
'name',
'email',
'role',
'is_active',
];
protected function name(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucwords($value),
set: fn (string $value) =>
strtolower(trim($value)),
);
}
protected function email(): Attribute
{
return Attribute::make(
set: fn (string $value) =>
strtolower(trim($value)),
);
}
protected function roleText(): Attribute
{
return Attribute::make(
get: fn ($value, array $attributes) => match ($attributes['role']) {
'admin' => 'Quản trị viên',
'editor' => 'Biên tập viên',
'user' => 'Người dùng',
default => 'Không xác định',
},
);
}
protected function statusText(): Attribute
{
return Attribute::make(
get: fn ($value, array $attributes) =>
$attributes['is_active']
? 'Đang hoạt động'
: 'Đã khóa',
);
}
}
Trong Blade:
{{ $user->name }}
{{ $user->email }}
{{ $user->role_text }}
{{ $user->status_text }}
Giả sử database:
name = "nguyen van a"
email = " ADMIN@GMAIL.COM "
role = "admin"
is_active = 1
Khi đọc:
$user->name
Kết quả:
Nguyen Van A
Khi đọc:
$user->email
Kết quả:
ADMIN@GMAIL.COM
Nếu chỉ khai báo Mutator cho email thì dữ liệu sẽ được chuẩn hóa khi lưu. Sau khi lưu, giá trị database sẽ là:
admin@gmail.com
Role:
$user->role_text
Kết quả:
Quản trị viên
Status:
$user->status_text
Kết quả:
Đang hoạt động
Có thể nhớ Accessor và Mutator bằng một câu:
ACCESSOR = Đọc dữ liệu → biến đổi → sử dụng
MUTATOR = Ghi dữ liệu → biến đổi → lưu
| Thành phần | Thời điểm | Mục đích |
|---|---|---|
| Accessor | Khi đọc | Format / tính toán dữ liệu |
| Mutator | Khi ghi | Chuẩn hóa dữ liệu |
| Cast | Khi đọc/ghi | Chuyển đổi kiểu dữ liệu |
Ví dụ:
MODEL
│
┌───────────┴───────────┐
│ │
ACCESSOR MUTATOR
│ │
▼ ▼
Khi đọc Khi ghi
│ │
▼ ▼
Format dữ liệu Chuẩn hóa dữ liệu
│ │
▼ ▼
Blade Database
Trong Model User, hãy tạo:
Mutator cho email:
ADMIN@GMAIL.COM
→
admin@gmail.com
Accessor cho name:
nguyen van a
→
Nguyen Van A
Accessor:
$user->role_text
Hiển thị:
admin → Quản trị viên
user → Người dùng
editor → Biên tập viên
Accessor:
$user->status_text
Hiển thị:
is_active = 1 → Đang hoạt động
is_active = 0 → Đã khóa
Tạo Accessor:
$user->display_name
Nếu name có dữ liệu thì hiển thị name, nếu không thì hiển thị:
Chưa cập nhật tên
Accessor
↓
Xử lý khi lấy dữ liệu
Mutator
↓
Xử lý khi gán dữ liệu
Attribute::make()
↓
Cú pháp hiện đại trong Laravel
$casts
↓
Chuyển đổi kiểu dữ liệu
Model
↓
Có thể chịu trách nhiệm format
và chuẩn hóa dữ liệu
Sau Bài 31, chúng ta đã biết cách để đưa logic xử lý thuộc tính vào Eloquent Model thay vì nhồi tất cả vào Controller hoặc Blade.
Đây là một bước quan trọng để source Laravel bắt đầu sạch và có cấu trúc hơn.
Bài tiếp theo — Bài 32: Local Scope & Global Scope.
x1
quay về MỤC LỤC
Cập nhật: 2026-08-25T20:30:07.395+07:00