Electron là một framework mã nguồn mở cho phép bạn xây dựng ứng dụng desktop đa nền tảng (Windows, macOS, Linux) chỉ với JavaScript, HTML và CSS, bằng cách kết hợp Chromium và Node.js trong cùng một môi trường.
Nó giúp lập trình viên web dễ dàng tạo ứng dụng desktop mà không cần học thêm ngôn ngữ native như C++ hay Swift.
Cross-platform: Chạy trên Windows, macOS, Linux với cùng một codebase.
Kết hợp Chromium + Node.js:
Chromium để render giao diện (HTML, CSS, JS).
Node.js để xử lý backend, truy cập hệ thống file, API OS.
Mã nguồn mở: Được duy trì bởi cộng đồng dưới OpenJS Foundation.
Hệ sinh thái phong phú: Tích hợp dễ dàng với React, Vue, Angular, TypeScript, Tailwind CSS, v.v.
Tính năng hỗ trợ:
Auto-update ứng dụng.
Crash reporting.
Tạo installer (.dmg, .msi, .rpm).
Phân phối qua Mac App Store, Microsoft Store, Snap Store.
Visual Studio Code – IDE phổ biến nhất hiện nay.
Slack – ứng dụng chat cho doanh nghiệp.
Discord – nền tảng cộng đồng gamer.
Skype – ứng dụng gọi video.
Figma – công cụ thiết kế UI/UX.
Electron có hai tiến trình chính:
Main Process: Chạy Node.js, quản lý cửa sổ, menu, API hệ điều hành.
Renderer Process: Chạy Chromium, render giao diện web.
Ví dụ khởi tạo ứng dụng Electron:
const { app, BrowserWindow } = require('electron')
function createWindow () {
const win = new BrowserWindow({ width: 800, height: 600 })
win.loadFile('index.html')
}
app.whenReady().then(createWindow)
| Ưu điểm | Nhược điểm |
|---|---|
| Dễ học với lập trình viên web | Ứng dụng thường nặng hơn native |
| Một codebase chạy đa nền tảng | Tốn RAM và CPU hơn |
| Hệ sinh thái npm phong phú | Kích thước file cài đặt lớn |
| Hỗ trợ auto-update, crash report | Không phù hợp cho ứng dụng cần hiệu năng cao |
Bạn có muốn mình phân tích sâu hơn về ứng dụng thực tế của Electron hay so sánh Electron vs Native để thấy rõ khi nào nên chọn Electron?
Cập nhật: 2026-10-04T20:54:17.047+07:00
Trong các bài trước của khóa Laravel 13, chúng ta đã làm việc chủ yếu với:
PHP
Laravel
Blade
Database
Eloquent
REST API
Laravel Sanctum
Những thành phần này chủ yếu xử lý phía Server.
Nhưng một ứng dụng web hiện đại không chỉ có Server.
Khi người dùng:
bấm nút,
mở Modal,
gửi Form,
tìm kiếm,
lọc dữ liệu,
upload ảnh,
tải dữ liệu không cần reload trang,
cập nhật giao diện ngay lập tức,
thì chúng ta cần đến JavaScript.
Vì vậy, trước khi học Vue, React hoặc các Frontend Framework khác, chúng ta cần nắm được JavaScript hiện đại.
JavaScript là ngôn ngữ lập trình thường được sử dụng để xây dựng các tương tác phía trình duyệt.
Ví dụ một trang Laravel có nút:
<button id="btnHello">
Click me
</button>
JavaScript có thể bắt sự kiện click:
const button = document.querySelector('#btnHello');
button.addEventListener('click', () => {
alert('Hello Laravel!');
});
Khi người dùng click vào nút, JavaScript được thực thi ngay trên trình duyệt.
Có thể hình dung:
WEB APPLICATION
│
┌────────────┴────────────┐
│ │
SERVER BROWSER
│ │
Laravel JavaScript
│ │
PHP/DB HTML/CSS/JS
│ │
└───────────┬─────────────┘
│
HTTP
Laravel xử lý phần Server.
JavaScript xử lý phần tương tác phía Browser.
Một hiểu lầm phổ biến của người mới học Laravel là:
"Đã dùng Laravel thì không cần JavaScript."
Điều này không đúng.
Laravel và JavaScript giải quyết những vấn đề khác nhau.
Ví dụ:
Laravel
│
├── Route
├── Controller
├── Model
├── Database
├── Authentication
└── REST API
JavaScript
│
├── Click
├── Form interaction
├── DOM
├── AJAX / Fetch
├── API request
└── Dynamic UI
Một ứng dụng Laravel thực tế thường sử dụng cả hai.
Giả sử chúng ta có một trang quản lý bài viết.
Laravel trả về:
<button id="deletePost">
Xóa bài viết
</button>
JavaScript có thể xử lý hành động:
const button = document.querySelector('#deletePost');
button.addEventListener('click', () => {
const confirmed = confirm('Bạn có chắc muốn xóa?');
if (confirmed) {
console.log('Delete post');
}
});
Trong ví dụ này:
User
│
│ Click
▼
JavaScript
│
│ xử lý interaction
▼
Laravel API
│
│ xử lý request
▼
Database
Đây là mô hình mà chúng ta sẽ sử dụng rất nhiều trong các bài sau.
Trong một project Laravel hiện đại, JavaScript thường nằm trong:
resources/
└── js/
Ví dụ:
resources/
└── js/
├── app.js
└── bootstrap.js
Trong đó:
app.js
là một trong những file JavaScript chính của ứng dụng.
Chúng ta có thể viết JavaScript trực tiếp trong file này.
Ví dụ:
console.log('Laravel + JavaScript');
Sau đó Laravel sử dụng Vite để xử lý asset frontend trong quá trình phát triển và build ứng dụng.
Trong các project Laravel hiện đại, Vite đóng vai trò là công cụ frontend build/development.
Có thể hình dung:
JavaScript
CSS
Images
│
▼
Vite
│
▼
Browser
Vite hỗ trợ:
Development server
Module JavaScript
Import / Export
CSS processing
Hot Module Replacement
Build production
Ví dụ trong resources/js/app.js:
import './bootstrap';
console.log('Hello Laravel');
Vite sẽ xử lý module JavaScript và cung cấp chúng cho ứng dụng.
JavaScript đã phát triển rất nhiều.
Cách viết cũ có thể như:
var name = 'Laravel';
function hello(name) {
return 'Hello ' + name;
}
JavaScript hiện đại cung cấp nhiều cú pháp tiện lợi hơn:
const name = 'Laravel';
const hello = (name) => {
return `Hello ${name}`;
};
Chúng ta sẽ học những tính năng này trong Bài 02 — ES6+.
Một số tính năng quan trọng:
ES6+
│
├── let / const
├── Arrow Function
├── Template Literal
├── Destructuring
├── Spread Operator
├── Default Parameter
├── Optional Chaining
├── Nullish Coalescing
├── Modules
├── Promise
└── Async / Await
Đây chính là nền tảng cho Vue và React.
DOM (Document Object Model) là mô hình mà trình duyệt sử dụng để biểu diễn HTML của trang web.
Ví dụ:
<h1 id="title">
Laravel 13
</h1>
JavaScript có thể lấy phần tử này:
const title = document.querySelector('#title');
Sau đó thay đổi nội dung:
title.textContent = 'JavaScript';
Kết quả trên trình duyệt:
JavaScript
Chúng ta có thể thay đổi:
Nội dung
Class
Attribute
Style
Element
Form
Event
Ví dụ:
title.classList.add('active');
Một ứng dụng web có rất nhiều Event.
Ví dụ:
click
submit
change
input
keydown
keyup
mouseover
load
Ví dụ:
<button id="btn">
Click
</button>
JavaScript:
const button = document.querySelector('#btn');
button.addEventListener('click', () => {
console.log('Button clicked');
});
Mô hình:
User
│
│ click
▼
Browser
│
▼
JavaScript Event
│
▼
Callback Function
Đây là kiến thức rất quan trọng.
Vue và React sau này vẫn làm việc với Event, chỉ khác cách tổ chức và cách chúng ta mô tả giao diện.
Ví dụ một Form đăng nhập:
<form id="loginForm">
<input
type="email"
id="email"
>
<input
type="password"
id="password"
>
<button type="submit">
Login
</button>
</form>
JavaScript có thể bắt sự kiện submit:
const form = document.querySelector('#loginForm');
form.addEventListener('submit', (event) => {
event.preventDefault();
console.log('Form submitted');
});
event.preventDefault() ngăn trình duyệt thực hiện hành động mặc định của Form.
Từ đây chúng ta có thể:
Form
│
▼
JavaScript
│
├── Validate
├── Collect data
├── Convert to JSON
└── Send API request
Đây chính là nền tảng cho các Form AJAX/API sau này.
Đây là phần cực kỳ quan trọng của khóa học.
Laravel có thể cung cấp API:
GET /api/posts
JavaScript có thể gọi API đó bằng fetch():
fetch('/api/posts')
.then(response => response.json())
.then(data => {
console.log(data);
});
Mô hình:
Browser
│
│ fetch()
▼
Laravel API
│
▼
Controller
│
▼
Eloquent
│
▼
Database
Sau đó dữ liệu quay trở lại:
Database
│
▼
Eloquent
│
▼
Laravel API
│
▼
JSON
│
▼
JavaScript
│
▼
HTML
Trong các bài tiếp theo chúng ta sẽ học kỹ hơn về quy trình này.
Laravel API thường trả dữ liệu dưới dạng JSON.
Ví dụ:
{
"id": 1,
"title": "Laravel 13",
"status": "published"
}
JavaScript có thể nhận dữ liệu:
fetch('/api/posts/1')
.then(response => response.json())
.then(post => {
console.log(post.title);
});
Kết quả:
Laravel 13
Có thể hình dung:
Laravel
│
│ JSON
▼
JavaScript
JSON trở thành một trong những định dạng dữ liệu quan trọng nhất khi xây dựng ứng dụng Frontend + Backend.
Sau khi học JavaScript thuần, chúng ta sẽ gặp một vấn đề.
Khi ứng dụng lớn hơn, JavaScript bắt đầu có nhiều phần:
JavaScript
│
├── DOM
├── Event
├── Form
├── API
├── State
├── Modal
├── Search
├── Pagination
└── UI
Nếu tất cả nằm trong một vài file JavaScript lớn, việc quản lý ứng dụng sẽ trở nên khó khăn.
Đây là lúc các Frontend Framework trở nên hữu ích.
Trong khóa học này, chúng ta đi theo hướng:
JavaScript
│
▼
Vue
│
▼
Vue + Laravel API
│
▼
Blog SPA
Sau đó mới mở rộng sang:
React
│
▼
React + Laravel API
và:
Livewire
│
▼
Laravel + Livewire
Vue không phải là một ngôn ngữ hoàn toàn mới.
Vue được xây dựng trên nền tảng JavaScript.
Ví dụ JavaScript:
const name = 'Laravel';
Vue:
const name = ref('Laravel');
JavaScript:
button.addEventListener('click', () => {
count++;
});
Vue:
const count = ref(0);
function increment() {
count.value++;
}
Nếu chưa hiểu:
biến
function
object
array
event
module
Promise
async/await
thì khi học Vue sẽ rất dễ trở thành học thuộc cú pháp.
Mục tiêu của phần này là tránh điều đó.
Một trong những mục tiêu của khóa học là giúp người học nhìn thấy mối quan hệ giữa các công nghệ.
JavaScript
│
┌─────────┴─────────┐
│ │
Vue React
│ │
└─────────┬─────────┘
│
Laravel API
Vue và React có nhiều khái niệm tương đồng:
Component
State
Event
Props
API
Form
Authentication
Nhưng cú pháp và hệ sinh thái khác nhau.
Vì vậy, học JavaScript trước sẽ giúp chúng ta dễ chuyển đổi giữa các Framework hơn.
Không phải ứng dụng Laravel nào cũng cần Vue hoặc React.
Một ứng dụng có thể sử dụng:
Laravel
│
└── Blade
│
└── JavaScript
Ví dụ:
Admin Dashboard
│
├── Blade
├── Bootstrap
└── JavaScript
Với những tương tác đơn giản, JavaScript thuần có thể đã đủ.
Không nhất thiết phải biến mọi trang thành SPA.
Ví dụ một trang có:
Modal
Dropdown
Confirm Delete
Image Preview
Character Counter
Form Validation
Toggle Menu
Chúng ta hoàn toàn có thể sử dụng JavaScript.
Ví dụ:
const preview = document.querySelector('#preview');
const input = document.querySelector('#image');
input.addEventListener('change', () => {
const file = input.files[0];
if (!file) {
return;
}
preview.src = URL.createObjectURL(file);
});
Không cần Vue.
Không cần React.
Không cần SPA.
Nếu giao diện có nhiều trạng thái và tương tác:
Search
Filter
Pagination
Modal
Form
API
Loading
Error
Authentication
User State
thì việc quản lý bằng JavaScript thuần có thể trở nên phức tạp.
Vue cung cấp cách tổ chức ứng dụng theo Component.
Ví dụ:
Blog
│
├── Navbar
├── SearchBox
├── CategoryFilter
├── PostList
│ └── PostCard
├── Pagination
└── Footer
Mỗi thành phần có thể được tổ chức riêng.
Trong phần 3 của khóa học, chúng ta sẽ kết nối hai phía:
LARAVEL
│
┌───────┴────────┐
│ │
REST API Database
│
│ JSON
▼
VUE
│
├── Components
├── Router
├── Pinia
└── UI
Ví dụ:
Vue
│
│ GET /api/posts
▼
Laravel
│
▼
PostController
│
▼
Post Model
│
▼
MySQL
Kết quả:
{
"data": [
{
"id": 1,
"title": "Laravel 13"
},
{
"id": 2,
"title": "JavaScript"
}
]
}
Vue nhận JSON và hiển thị danh sách.
Trong phần này, chúng ta sẽ đi từ cơ bản đến kết nối Laravel API:
Bài 01
JavaScript trong Laravel
│
▼
Bài 02
ES6+
│
▼
Bài 03
Array & Object
│
▼
Bài 04
Module
│
▼
Bài 05
Promise & Async/Await
│
▼
Bài 06
Fetch API
│
▼
Bài 07
JSON & HTTP
│
▼
Bài 08
JavaScript + Laravel REST API
Sau Bài 08, người học sẽ có nền tảng để bắt đầu:
VUE
│
▼
VUE + LARAVEL API
│
▼
BLOG SPA
Có thể chia thành 5 nhóm chính.
let
const
arrow function
template literal
destructuring
spread
rest
optional chaining
Array
Object
Map
Set
JSON
import
export
Promise
async
await
try / catch
Fetch API
DOM
Event
Form
Fetch
Storage
URL
File
Đây là những kiến thức chúng ta sẽ sử dụng xuyên suốt phần Frontend.
JavaScript ngày nay không chỉ chạy trong Browser.
Có thể sử dụng JavaScript với:
Browser
│
└── Frontend
Node.js
│
└── Backend
Vue
│
└── Frontend Framework
React
│
└── Frontend Library
Electron
│
└── Desktop Application
React Native
│
└── Mobile Application
Trong khóa học này, chúng ta tập trung vào JavaScript phía Browser để phục vụ:
Laravel
+
JavaScript
+
Vue
+
React
Tạo một file:
resources/js/app.js
Thêm:
console.log('JavaScript is working!');
Sau đó tạo một Blade view:
resources/views/javascript.blade.php
Nội dung:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>JavaScript + Laravel</title>
@vite(['resources/js/app.js'])
</head>
<body>
<h1 id="title">
Laravel 13
</h1>
<button id="changeTitle">
Change Title
</button>
</body>
</html>
Trong app.js:
const button = document.querySelector('#changeTitle');
button.addEventListener('click', () => {
const title = document.querySelector('#title');
title.textContent = 'JavaScript is working!';
});
Khi click:
Laravel 13
│
│ click
▼
JavaScript
│
▼
DOM
│
▼
JavaScript is working!
Mở trang trong trình duyệt.
Ban đầu:
Laravel 13
[ Change Title ]
Sau khi click:
JavaScript is working!
[ Change Title ]
Mở Developer Tools → Console.
Chúng ta cũng sẽ thấy:
JavaScript is working!
Nếu thấy dòng này, JavaScript đã được load thành công.
Sau toàn bộ khóa học, chúng ta sẽ đi từ:
LARAVEL 13
│
┌─────────┴─────────┐
│ │
Blade API
│ │
JavaScript │
│ │
▼ ▼
Vue ◄──────────── Laravel
│
Vue Router
│
Pinia
│
▼
Blog SPA
Sau đó mở rộng:
Laravel API
│
┌────────┼────────┐
│ │ │
Vue React JavaScript
│ │
└────────┘
│
Frontend
Và cuối cùng so sánh với:
Laravel
│
├── Blade
│
├── Livewire
│
├── Vue
│
└── React
Mục tiêu không phải là học thật nhiều Framework.
Mục tiêu là hiểu vai trò của từng công nghệ và biết khi nào chúng ta cần sử dụng chúng.
Trong bài đầu tiên, chúng ta đã xác định vị trí của JavaScript trong hệ sinh thái Laravel.
Laravel
│
├── Server
│
├── Database
│
├── REST API
│
└── Blade
│
▼
JavaScript
│
┌────┴────┐
▼ ▼
Vue React
│
▼
Laravel API
Những kiến thức quan trọng cần nhớ:
Laravel xử lý phần Server.
JavaScript xử lý tương tác phía Browser.
Vite hỗ trợ quá trình phát triển và build frontend.
JavaScript có thể thao tác DOM và xử lý Event.
JavaScript có thể gửi request tới Laravel API.
JSON là định dạng dữ liệu quan trọng giữa Frontend và Backend.
Vue và React đều dựa trên nền tảng JavaScript.
Không phải ứng dụng Laravel nào cũng cần Vue hoặc React.
JavaScript thuần vẫn rất hữu ích trong các ứng dụng Blade.
Học JavaScript trước giúp việc học Vue và React dễ hiểu hơn.
Ở bài tiếp theo, chúng ta sẽ bắt đầu đi vào ES6+ — tập hợp những cú pháp và tính năng JavaScript hiện đại sẽ xuất hiện liên tục trong Vue, React và các ứng dụng Frontend hiện đại.
Bài 02 — ES6+
Cập nhật: 2026-10-07T20:25:46.594+07:00
Nếu cập nhật theo Laravel 12 LTS (2026) thì mình sẽ chia thành các phần như sau để người học đi từ PHP Core → Laravel → Triển khai dự án thực tế.
Tiếp nối khóa "PHP Core 8.2 — Tự học PHP từ ZERO đến làm web động"
laravel newphp artisan serveassertOk()assertStatus(200)assertJson()Bonus PHPUnit → chỉ 01 bài để giới thiệu kiểm thử, không làm khóa học quá nặng nhưng vẫn đủ để người học biết cách kiểm tra API và các chức năng cơ bản.
Cập nhật: 2026-09-30T06:23:00.217+07:00
Sau 49 bài, chúng ta đã đi từ những khái niệm đầu tiên của Laravel đến việc xây dựng một Blog CMS hoàn chỉnh.
Trong bài cuối cùng này, chúng ta sẽ không học thêm một tính năng mới.
Thay vào đó, chúng ta sẽ nhìn lại toàn bộ dự án:
Đã học những gì?
Các thành phần kết nối với nhau như thế nào?
Source code cần tối ưu ra sao?
Cần kiểm tra gì trước khi đưa website lên Internet?
Một dự án Laravel 13 hoàn chỉnh cần có những thành phần nào?
Đây cũng là bước chuyển từ:
"Học Laravel"
sang:
"Có thể tự xây dựng và triển khai một ứng dụng Laravel thực tế."
Trong suốt khóa học, chúng ta xây dựng một hệ thống Blog CMS có các chức năng chính:
BLOG CMS
│
┌──────────────┼──────────────┐
│ │ │
FRONTEND ADMIN API
│ │ │
▼ ▼ ▼
Blog list Dashboard REST API
Post detail Categories Sanctum
Category Posts Resources
Search Users Authentication
│ │ │
└──────────────┼──────────────┘
│
DATABASE
│
┌────────────┼────────────┐
▼ ▼ ▼
Users Categories Posts
Các thành phần chính gồm:
Authentication
Authorization
Users
Categories
Posts
Relationships
Validation
Upload Image
Search
Pagination
DataTables
Soft Delete
Events
Listeners
Queue
Mail
Notification
Cache
Storage
Logging
REST API
API Resource
Sanctum
Deployment
Nginx
Queue Worker
Scheduler
Production
Đây không còn là một ví dụ CRUD đơn giản.
Nó đã gần với cấu trúc của một ứng dụng web thực tế.
Chúng ta bắt đầu bằng những thành phần cơ bản:
Laravel
│
├── Composer
├── Artisan
├── MVC
├── Routing
├── Controller
└── Blade
Ví dụ một request:
Browser
│
▼
Route
│
▼
Controller
│
▼
Model
│
▼
Database
│
▼
Controller
│
▼
Blade
│
▼
HTML
│
▼
Browser
Đây là flow quan trọng nhất cần hiểu.
Chúng ta sử dụng Migration để quản lý cấu trúc database.
Ví dụ:
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
Thay vì tạo bảng thủ công bằng phpMyAdmin, cấu trúc database được lưu trong source code.
Điều này rất quan trọng khi làm việc nhóm hoặc triển khai lên server.
Seeder giúp tạo dữ liệu ban đầu:
php artisan db:seed
Factory giúp tạo dữ liệu giả:
Post::factory()->count(50)->create();
Nhờ đó chúng ta có thể nhanh chóng tạo:
100 Users
100 Categories
1000 Posts
để kiểm tra:
Pagination
Search
DataTables
Performance
Relationship
Eloquent là một trong những thành phần quan trọng nhất của Laravel.
Ví dụ:
$posts = Post::latest()->get();
Thay vì viết SQL trực tiếp:
SELECT *
FROM posts
ORDER BY created_at DESC;
Eloquent giúp code dễ đọc hơn.
Ví dụ:
$post = Post::find(1);
$post->update([
'title' => 'Laravel 13'
]);
Xóa:
$post->delete();
Blog CMS sử dụng nhiều Relationship.
Ví dụ:
User
│
└── hasMany()
│
▼
Posts
Category
│
└── hasMany()
│
▼
Posts
Và:
Post
│
├── belongsTo(User)
│
└── belongsTo(Category)
Trong Model:
public function user()
{
return $this->belongsTo(User::class);
}
public function category()
{
return $this->belongsTo(Category::class);
}
Sau đó:
$post->user->name;
$post->category->name;
Đây chính là sức mạnh của Eloquent Relationship.
Không nên tin dữ liệu từ người dùng.
Ví dụ:
$request->validate([
'title' => ['required', 'string', 'max:255'],
'content' => ['required'],
'category_id' => ['required', 'exists:categories,id'],
]);
Validation giúp đảm bảo dữ liệu trước khi đưa vào database.
Đối với upload:
'image' => [
'nullable',
'image',
'mimes:jpg,jpeg,png,webp',
'max:3072',
],
Validation phải được thực hiện ở server.
JavaScript phía client chỉ nên đóng vai trò hỗ trợ trải nghiệm người dùng.
Breeze giúp chúng ta xây dựng:
Register
Login
Logout
Password
Session
Authentication
Sau khi đăng nhập:
auth()->user()
có thể lấy thông tin user hiện tại.
Ví dụ:
$user = auth()->user();
echo $user->name;
Authentication trả lời:
Người này đã đăng nhập chưa?
Authorization trả lời:
Người này có quyền thực hiện hành động này không?
Ví dụ:
User
├── xem bài viết
└── không được quản lý user
Admin
├── xem bài viết
├── quản lý category
├── quản lý post
└── quản lý user
Đây là lý do chúng ta sử dụng:
Middleware
Policy
Gates
Role
Không nên chỉ ẩn nút bằng HTML.
Quyền phải được kiểm tra ở server.
Category là module CRUD đầu tiên của dự án.
Create
Read
Update
Delete
Ngoài ra còn:
Search
Pagination
Validation
Active / Inactive
Model có Scope:
#[Scope]
protected function active(Builder $query): void
{
$query->where('is_active', true);
}
Sau đó:
Category::active()->get();
Đây là một ví dụ về việc đưa logic truy vấn vào Model thay vì lặp lại trong Controller.
Post là module trung tâm của Blog CMS.
Một Post có:
title
slug
excerpt
content
image
status
published_at
view_count
user_id
category_id
Quan hệ:
User
│
└── Posts
Category
│
└── Posts
Post
├── User
└── Category
Controller xử lý:
Create
Edit
Update
Delete
Publish
Upload
Validation
Relationship
Phần frontend của Blog sử dụng:
BlogController
│
├── index()
├── show()
└── category()
Ví dụ danh sách bài viết:
Post::published()
->whereHas('category', fn ($query) => $query->active())
->with(['user', 'category'])
->latest('published_at')
->paginate(10);
Đây là một câu query đã kết hợp:
Local Scope
Relationship
Eager Loading
Sorting
Pagination
Một trong những lỗi performance phổ biến:
$posts = Post::all();
foreach ($posts as $post) {
echo $post->user->name;
}
Có thể dẫn tới rất nhiều query.
Thay vào đó:
$posts = Post::with('user')->get();
Hoặc:
$posts = Post::with([
'user',
'category'
])->get();
Nguyên tắc:
Khi biết trước sẽ sử dụng Relationship, hãy cân nhắc eager loading.
Không nên lấy toàn bộ database:
Post::all();
rồi xử lý bằng PHP khi dữ liệu lớn.
Thay vào đó:
Post::query()
->where('title', 'like', "%{$keyword}%")
->latest()
->paginate(10);
Database xử lý phần lớn công việc.
Ứng dụng chỉ nhận số lượng dữ liệu cần thiết.
Chúng ta đã thêm:
use SoftDeletes;
vào Model.
Khi:
$post->delete();
Post không nhất thiết bị xóa khỏi database.
Nó được đánh dấu bằng:
deleted_at
Có thể lấy lại:
$post->restore();
Xóa vĩnh viễn:
$post->forceDelete();
Đây là cơ chế rất hữu ích cho CMS.
Event giúp tách các hành động phụ khỏi logic chính.
Ví dụ:
User Register
│
▼
Registered Event
│
▼
Listener
│
├── Send Email
├── Log
└── Notification
Controller không nhất thiết phải xử lý tất cả mọi việc.
Một số công việc không cần thực hiện ngay trong HTTP request.
Ví dụ:
Gửi email
Xử lý ảnh
Tạo báo cáo
Gửi notification
Import dữ liệu
Có thể đưa vào Queue:
Request
│
▼
Dispatch Job
│
▼
Response ngay
│
│
▼
Queue Worker
│
▼
Process Job
Nhờ đó website phản hồi nhanh hơn.
Laravel cung cấp hệ thống:
Mail
Notification
cho nhiều hình thức thông báo.
Ví dụ:
User đăng ký
│
▼
Notification
│
├── Mail
├── Database
└── các channel khác
Trong dự án thực tế, các tác vụ gửi mail số lượng lớn nên được kết hợp với Queue.
Cache dùng để giảm số lần truy vấn hoặc xử lý dữ liệu.
Ví dụ:
$categories = Cache::remember(
'blog.categories',
3600,
fn () => Category::active()->get()
);
Flow:
Request
│
▼
Cache?
┌─┴─┐
Có Không
│ │
▼ ▼
Data Database
│
▼
Cache
Nhưng cache phải có chiến lược invalidation.
Ví dụ khi Category thay đổi:
Cache::forget('blog.categories');
Không nên cache mọi thứ một cách máy móc.
File upload nên được quản lý thông qua Laravel Storage.
Ví dụ:
$path = $request->file('image')
->store('posts', 'public');
File có thể được lưu trong disk:
storage/app/public
và tạo symbolic link:
php artisan storage:link
Không nên tự xây dựng hệ thống upload bằng cách xử lý đường dẫn file một cách tùy tiện.
Khi production, log rất quan trọng.
Ví dụ:
Log::info('Post published', [
'post_id' => $post->id,
'user_id' => auth()->id(),
]);
Khi xảy ra lỗi:
Log::error('Post publish failed', [
'post_id' => $post->id,
]);
Development có thể sử dụng:
dd($data);
hoặc:
dump($data);
Nhưng production phải tránh để debug information xuất hiện cho người dùng.
Laravel 13 cũng cung cấp health route /up, hữu ích cho monitoring và kiểm tra ứng dụng đã boot thành công hay chưa.
Blog CMS của chúng ta không chỉ có giao diện HTML.
Chúng ta còn xây dựng API:
GET
POST
PUT / PATCH
DELETE
Ví dụ:
GET /api/posts
GET /api/posts/{id}
POST /api/posts
PUT /api/posts/{id}
DELETE /api/posts/{id}
API trả về JSON thay vì HTML.
Không nên trả trực tiếp toàn bộ Model:
return $post;
Đối với API thực tế, nên kiểm soát dữ liệu trả về bằng:
PostResource
Ví dụ:
return new PostResource($post);
Danh sách:
return PostResource::collection($posts);
Resource giúp API có cấu trúc rõ ràng và tránh vô tình trả những dữ liệu không nên public.
Sanctum cung cấp cơ chế xác thực cho API.
Flow:
Client
│
│ email + password
▼
API Login
│
▼
Sanctum
│
▼
Token
│
▼
Client
Các request tiếp theo:
Authorization: Bearer TOKEN
Route private:
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', function (Request $request) {
return $request->user();
});
});
Một API thực tế cần có:
Register
Login
Logout
Current User
Token
Validation
Authorization
Và phải phân biệt:
401 Unauthorized
với:
403 Forbidden
Hiểu đơn giản:
401
→ Chưa xác thực / token không hợp lệ
403
→ Đã xác thực nhưng không có quyền
Đây là nền tảng để xây dựng API cho:
Mobile App
SPA
Frontend JavaScript
Third-party integrations
Sau khi hoàn thành source code, ứng dụng phải được đưa lên server.
Kiến trúc phổ biến:
Internet
│
▼
Domain
│
▼
Nginx
│
▼
PHP-FPM
│
▼
Laravel 13
│
├── MySQL
├── Redis / Cache
├── Storage
└── Queue
Laravel 13 yêu cầu PHP tối thiểu 8.3. Khi dùng Nginx, web server phải trỏ vào thư mục public, không phải root của project.
Một production .env cơ bản có thể có:
APP_ENV=production
APP_DEBUG=false
APP_URL=https://example.com
Database:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=blog
DB_USERNAME=blog_user
DB_PASSWORD=********
Mail:
MAIL_MAILER=smtp
MAIL_HOST=...
MAIL_PORT=587
MAIL_USERNAME=...
MAIL_PASSWORD=...
MAIL_ENCRYPTION=tls
Không commit .env vào Git.
Đặc biệt:
APP_KEY
DB_PASSWORD
MAIL_PASSWORD
API_KEY
TOKEN
đều phải được bảo vệ.
Laravel cung cấp:
php artisan optimize
để cache các thành phần phù hợp cho production.
Có thể xóa cache bằng:
php artisan optimize:clear
Laravel 13 cũng cung cấp các lệnh cache riêng cho:
php artisan config:cache
php artisan event:cache
php artisan route:cache
php artisan view:cache
Các cache này nên được thực hiện trong quy trình deployment, không phải tùy tiện trong lúc development.
Khi deploy:
composer install --no-dev --optimize-autoloader
Không cần mang theo các package chỉ phục vụ development nếu chúng không cần thiết trên production.
Sau đó:
php artisan optimize
Nếu project sử dụng Vite:
npm install
Sau đó build production:
npm run build
Không nên sử dụng development server để phục vụ production.
Production migration:
php artisan migrate --force
--force cho phép migration chạy trong môi trường production mà không yêu cầu xác nhận tương tác.
Trước migration quan trọng:
Backup Database
│
▼
Run Migration
│
▼
Check Application
│
▼
Monitor Logs
Đừng biến production database thành nơi thử nghiệm.
Sau deployment:
php artisan storage:link
Laravel cần quyền ghi vào:
storage/
bootstrap/cache/
Đây là những thư mục quan trọng đối với hoạt động runtime và cache của ứng dụng.
Nếu Blog CMS sử dụng Queue, production phải có worker chạy liên tục.
Ví dụ:
php artisan queue:work
Không nên chỉ chạy thủ công trong terminal rồi đóng terminal.
Production thường cần:
Supervisor
hoặc
systemd
hoặc
một process manager tương đương
để worker tự khởi động lại khi gặp lỗi.
Sau khi deploy code mới, các process chạy lâu như queue worker cần được reload/restart để sử dụng code mới. Laravel 13 cung cấp:
php artisan reload
cho mục đích này.
Nếu project có scheduled tasks, server cần gọi:
php artisan schedule:run
thường xuyên theo cơ chế scheduler của Laravel.
Ví dụ:
00:00
│
▼
Scheduler
│
├── Clean old logs
├── Send reports
├── Publish scheduled posts
└── Cleanup temporary files
Đây là phần rất dễ bị quên khi chuyển từ localhost lên VPS.
Trước khi đưa Blog CMS lên Internet:
[ ] APP_ENV=production
[ ] APP_DEBUG=false
[ ] APP_URL chính xác
[ ] APP_KEY tồn tại
[ ] Database production chính xác
[ ] Database đã backup
[ ] .env không commit Git
[ ] composer install --no-dev
[ ] npm run build
[ ] migrate --force
[ ] storage:link
[ ] storage permission
[ ] bootstrap/cache permission
[ ] php artisan optimize
[ ] Nginx root = public/
[ ] PHP-FPM hoạt động
[ ] HTTPS hoạt động
[ ] Queue Worker hoạt động
[ ] Scheduler hoạt động
[ ] Mail hoạt động
[ ] Upload hoạt động
[ ] Login hoạt động
[ ] Authorization hoạt động
[ ] API hoạt động
[ ] /up hoạt động
[ ] Logs hoạt động
Một Blog CMS production tối thiểu cần kiểm tra:
[ ] APP_DEBUG=false
[ ] Không public .env
[ ] HTTPS
[ ] Password được hash
[ ] Validation
[ ] Authorization
[ ] CSRF protection
[ ] Mass Assignment protection
[ ] SQL Injection protection
[ ] XSS protection
[ ] Upload validation
[ ] File permission
[ ] Rate limiting nếu cần
[ ] API authentication
[ ] Token được bảo vệ
[ ] Database backup
[ ] Server update thường xuyên
Đặc biệt:
Đừng nhầm việc ẩn một nút trên giao diện với việc bảo vệ quyền truy cập.
Ví dụ:
@if(auth()->user()->role === 'admin')
<a href="/admin/users">
Users
</a>
@endif
chỉ là UI.
Controller hoặc Policy vẫn phải kiểm tra quyền.
Khi project lớn dần, database thường trở thành một trong những điểm cần quan tâm đầu tiên.
Không nên:
Post::all();
nếu database có hàng triệu record.
Nên:
Post::latest()->paginate(20);
Không nên lấy toàn bộ column nếu chỉ cần một số field:
Post::select([
'id',
'title',
'slug',
])->get();
Sử dụng index cho những column thường xuyên:
WHERE
ORDER BY
JOIN
UNIQUE
Ví dụ:
$table->string('slug')->unique();
đã tạo unique index cho slug.
Một source code tốt không phải source code có nhiều file nhất.
Mục tiêu là:
Dễ đọc
Dễ sửa
Dễ test
Dễ debug
Dễ deploy
Dễ mở rộng
Controller không nên trở thành:
God Controller
Ví dụ không nên để một Controller xử lý:
Database
Email
Upload
Payment
Notification
Report
API
tất cả trong một method khổng lồ.
Hãy chia trách nhiệm hợp lý.
Khi thêm một tính năng mới, có thể đi theo flow:
Requirement
│
▼
Database?
│
▼
Migration
│
▼
Model
│
▼
Relationship
│
▼
Request / Validation
│
▼
Controller
│
▼
Route
│
▼
View / API
│
▼
Authorization
│
▼
Test
│
▼
Deploy
Đây là workflow rất đáng ghi nhớ.
Một workflow đơn giản:
Local Development
│
▼
Git
│
▼
Git Repository
│
▼
VPS
│
▼
composer install
│
▼
npm run build
│
▼
migrate
│
▼
optimize
│
▼
reload services
Ví dụ:
git add .
git commit -m "Update blog CMS"
git push
Trên server:
git pull
composer install --no-dev --optimize-autoloader
npm install
npm run build
php artisan migrate --force
php artisan optimize
php artisan reload
Tất nhiên quy trình thực tế có thể khác tùy hạ tầng.
Một cấu trúc khái quát:
blog/
│
├── app/
│ ├── Http/
│ │ ├── Controllers/
│ │ ├── Middleware/
│ │ ├── Requests/
│ │ └── Resources/
│ │
│ ├── Models/
│ ├── Policies/
│ ├── Events/
│ ├── Listeners/
│ ├── Jobs/
│ ├── Mail/
│ └── Notifications/
│
├── bootstrap/
│
├── config/
│
├── database/
│ ├── factories/
│ ├── migrations/
│ └── seeders/
│
├── public/
│
├── resources/
│ ├── css/
│ ├── js/
│ └── views/
│
├── routes/
│ ├── web.php
│ └── api.php
│
├── storage/
│
├── tests/
│
├── .env
├── artisan
├── composer.json
└── package.json
Cấu trúc Laravel có thể được tổ chức thêm tùy dự án, nhưng các thư mục cốt lõi được framework cung cấp nhằm tạo nền tảng cho cả ứng dụng nhỏ và lớn.
Có thể tóm tắt khóa Laravel 13 thành:
PHP
│
▼
Laravel
│
├── Routing
│
├── Controller
│
├── Blade
│
├── Database
│ ├── Migration
│ ├── Seeder
│ └── Factory
│
├── Eloquent
│ ├── CRUD
│ ├── Query
│ └── Relationship
│
├── Form
│ ├── Request
│ ├── Validation
│ └── Upload
│
├── Authentication
│ ├── Login
│ ├── Register
│ ├── Middleware
│ └── Authorization
│
├── Advanced
│ ├── Scope
│ ├── Observer
│ ├── Event
│ ├── Queue
│ ├── Mail
│ ├── Notification
│ ├── Cache
│ ├── Storage
│ └── Logging
│
├── REST API
│ ├── Resource
│ ├── Sanctum
│ └── Authentication
│
└── Production
├── VPS
├── Nginx
├── Queue Worker
├── Scheduler
└── Optimization
Đó chính là toàn bộ hành trình của khóa học.
Laravel 13 tiếp tục chu kỳ phát hành hàng năm của Laravel.
Theo tài liệu chính thức, Laravel 13 yêu cầu PHP 8.3–8.5; Laravel 13 được phát hành ngày 17/03/2026, với bug fixes đến Q3/2027 và security fixes đến 17/03/2028. Laravel 13 không phải bản LTS.
Vì vậy, nếu xây dựng khóa học hiện tại theo Laravel 13 thì nên ghi:
Laravel 13.x — 2026
thay vì:
Laravel 12 LTS
Sau khi hoàn thành khóa học, người học không chỉ biết:
Route::get(...);
hay:
Post::all();
Mà đã hiểu được cách một ứng dụng web thực tế được xây dựng:
User
│
▼
Browser
│
▼
Nginx
│
▼
Laravel
│
├── Middleware
├── Route
├── Controller
├── Validation
├── Model
├── Database
├── Cache
├── Queue
└── Storage
│
▼
Response
Đây mới là mục tiêu chính của khóa học.
Sau Blog CMS, có thể tiếp tục theo nhiều hướng.
Laravel
↓
REST API
↓
Redis
↓
Queue
↓
WebSocket
↓
Microservices
Laravel API
↓
Vue
React
Livewire
Linux
↓
Nginx
↓
Docker
↓
CI/CD
↓
Cloud
MySQL
↓
Index
↓
Query Optimization
↓
Redis
↓
Database Scaling
PHPUnit
↓
Feature Test
↓
API Test
↓
Integration Test
Phần Bonus của khóa học chỉ cần tập trung vào những khái niệm cơ bản:
Test
Feature Test
API Test
assertOk()
assertStatus()
assertJson()
Ví dụ:
$response = $this->get('/blog');
$response->assertOk();
API:
$response = $this->getJson('/api/posts');
$response->assertStatus(200);
Kiểm tra JSON:
$response->assertJson([
'data' => [],
]);
Mục tiêu của phần Bonus không phải biến người học thành chuyên gia testing.
Mục tiêu là giúp người học hiểu:
Code có thể được kiểm tra tự động thay vì chỉ test bằng tay.
Đây là danh sách chức năng hoàn chỉnh:
BLOG CMS
│
├── Authentication
│ ├── Register
│ ├── Login
│ └── Logout
│
├── Dashboard
│
├── Categories
│ ├── Create
│ ├── Read
│ ├── Update
│ ├── Delete
│ ├── Search
│ └── Pagination
│
├── Posts
│ ├── Create
│ ├── Read
│ ├── Update
│ ├── Delete
│ ├── Publish
│ ├── Upload Image
│ ├── Search
│ └── Pagination
│
├── Users
│ ├── List
│ ├── Role
│ ├── Active / Inactive
│ └── Password
│
├── Soft Delete
│
├── Roles & Permissions
│
├── Events
│
├── Queue
│
├── Mail
│
├── Notification
│
├── Cache
│
├── Storage
│
├── Logging
│
├── REST API
│
├── API Resource
│
├── Sanctum
│
└── Deployment
├── Shared Hosting
├── VPS
├── Nginx
├── Queue Worker
└── Scheduler
Trước khi gọi project là hoàn thành:
[✓] Register
[✓] Login
[✓] Logout
[✓] Categories
[✓] Posts
[✓] Users
[✓] Upload
[✓] Search
[✓] Pagination
[✓] Soft Delete
[✓] Roles
[✓] API
[✓] Sanctum
[✓] Validation
[✓] Relationship
[✓] Scope
[✓] Authorization
[✓] Error handling
[✓] Logging
[✓] Cache
[✓] Queue
[✓] .env
[✓] APP_DEBUG=false
[✓] HTTPS
[✓] Database
[✓] Storage
[✓] Nginx
[✓] PHP-FPM
[✓] Queue Worker
[✓] Scheduler
[✓] Backup
[✓] Monitoring
Đây là điểm quan trọng nhất của toàn bộ khóa học.
Học Laravel không nên dừng ở:
Biết Route
Biết Controller
Biết Model
Biết Blade
Mà phải tiến đến:
Nhận Requirement
↓
Thiết kế Database
↓
Thiết kế Architecture
↓
Viết Code
↓
Validation
↓
Authorization
↓
Testing
↓
Debug
↓
Optimization
↓
Deployment
↓
Monitoring
Đó mới là quy trình làm một ứng dụng thực tế.
Chúng ta bắt đầu từ:
PHP
sau đó:
Laravel
rồi:
Database
tiếp tục:
Eloquent
sau đó:
Authentication
rồi:
CRUD
tiếp tục:
API
và cuối cùng:
Production
Toàn bộ hành trình có thể tóm tắt bằng một dòng:
PHP
↓
Laravel 13
↓
Blog CMS
↓
REST API
↓
Authentication
↓
Queue / Cache / Storage
↓
VPS / Nginx
↓
Production
Nếu đã hoàn thành toàn bộ dự án Blog CMS, người học đã có một nền tảng đủ để bắt đầu xây dựng những ứng dụng Laravel lớn hơn.
Điều quan trọng từ đây không còn là:
"Laravel có bao nhiêu tính năng?"
mà là:
"Tôi có thể dùng Laravel để giải quyết bài toán gì?"
Framework chỉ là công cụ.
Kỹ năng quan trọng hơn là khả năng:
Phân tích vấn đề
↓
Thiết kế giải pháp
↓
Viết code
↓
Kiểm tra
↓
Tối ưu
↓
Triển khai
↓
Bảo trì
Và đó cũng chính là mục tiêu cuối cùng của khóa học:
┌─────────────────────────────────────────┐
│ │
│ PHP → LARAVEL 13 → CMS │
│ │
│ 50 BÀI HỌC + BONUS PHPUnit │
│ │
│ Database │
│ Eloquent │
│ Authentication │
│ CRUD │
│ API │
│ Sanctum │
│ Queue │
│ Cache │
│ Storage │
│ Deployment │
│ │
│ BLOG CMS │
│ │
└─────────────────────────────────────────┘
Hết phần Laravel 13.
Bước tiếp theo không phải học thêm thật nhiều syntax.
Hãy lấy Blog CMS này làm nền tảng và bắt đầu xây dựng project của riêng mình.
x1
Cập nhật: 2026-09-29T02:30:03.955+07:00
Trong 50 bài chính, chúng ta đã hoàn thành một Blog CMS với:
Authentication
CRUD
Database
Eloquent
Relationship
Validation
Upload
Search
Pagination
Soft Delete
Authorization
Queue
Notification
Cache
Storage
Logging
REST API
Sanctum
Deployment
Nhưng có một vấn đề:
Làm sao biết code của chúng ta vẫn hoạt động đúng sau khi sửa hoặc thêm tính năng mới?
Đó là lúc Automated Testing xuất hiện.
Trong bài Bonus này, chúng ta chỉ học những kiến thức PHPUnit cần thiết để bắt đầu kiểm thử một ứng dụng Laravel 13.
p/S: mục 8 trở đi mới chạy được.
Testing là quá trình kiểm tra xem chương trình có hoạt động đúng theo yêu cầu hay không.
Ví dụ Blog CMS có chức năng:
GET /blog
Chúng ta mong muốn:
HTTP 200
Thay vì mỗi lần sửa code lại mở trình duyệt kiểm tra bằng tay, chúng ta có thể viết test:
$response = $this->get('/blog');
$response->assertOk();
Sau đó Laravel tự kiểm tra.
Nếu đúng:
PASS
Nếu sai:
FAIL
PHPUnit là framework testing phổ biến trong PHP.
Laravel tích hợp sẵn khả năng testing với PHPUnit và Pest. Một project Laravel mới đã có phpunit.xml và thư mục tests.
Trong bài này chúng ta tập trung vào:
PHPUnit
vì mục tiêu của Bonus là giúp người mới hiểu nền tảng testing.
Trong Laravel:
tests/
│
├── Feature/
│
├── Unit/
│
└── TestCase.php
Hai loại quan trọng:
Unit Test
Feature Test
Unit Test kiểm tra một phần nhỏ và tương đối độc lập của code.
Ví dụ:
Một method
Một class
Một hàm xử lý
Một business rule
Ví dụ:
public function add(int $a, int $b): int
{
return $a + $b;
}
Test:
$this->assertEquals(
5,
$calculator->add(2, 3)
);
Unit Test thường không cần boot toàn bộ ứng dụng Laravel hoặc truy cập database.
Feature Test kiểm tra một chức năng lớn hơn.
Ví dụ:
Browser
↓
Route
↓
Middleware
↓
Controller
↓
Model
↓
Database
↓
Response
Ví dụ:
$response = $this->get('/blog');
$response->assertOk();
Đây là một Feature Test.
Laravel lưu ý rằng Feature Test có thể kiểm tra cả HTTP request, database và nhiều thành phần phối hợp với nhau; trong ứng dụng thực tế, phần lớn test thường có thể tập trung vào Feature Test vì nó cho mức độ tin cậy cao hơn về hành vi tổng thể của hệ thống.
Laravel cung cấp Artisan command:
php artisan make:test BlogTest
File sẽ nằm trong:
tests/Feature/BlogTest.php
Nếu muốn tạo Unit Test:
php artisan make:test CalculatorTest --unit
File:
tests/Unit/CalculatorTest.php
Tạo:
php artisan make:test BlogTest
Sau đó mở:
tests/Feature/BlogTest.php
Ví dụ:
<?php
// php artisan make:test BlogTest
namespace Tests\Feature;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
//php artisan test --filter=BlogTest
class BlogTest extends TestCase
{
use RefreshDatabase;
// Tự động nạp dữ liệu mẫu từ DatabaseSeeder vào database testing
protected $seed = true;
public function test_blog_page_is_accessible(): void
{
$response = $this->get('/blog');
$response->assertOk();
}
}
Test này nói:
Khi request
/blog, ứng dụng phải trả HTTP 200.
Trước khi chạy Test phải vào file Pest.php comment dòng này lại nếu không sẽ kích hoạt refresh database mất hết dữ liệu.
pest()->extend(TestCase::class)
// ->use(RefreshDatabase::class) // Comment dòng này lại
->in('Feature');
Tóm lại:&
dùng file .env.testing ngay từ đầu + thêm biến seed để laravel lấy dữ liệu mẫu tránh chung đụng dữ liệu testing và dữ liệu thật.
Xem mục&17. Test Post
Có thể chạy:
php artisan test
Laravel cũng cho phép chạy trực tiếp PHPUnit:
vendor/bin/phpunit
Laravel cung cấp php artisan test như một cách thuận tiện để chạy test suite.
Kết quả có thể giống:
PASS Tests\Feature\BlogTest
✓ blog page is accessible
Tests: 1 passed
Nếu test thất bại:
FAIL
Laravel sẽ hiển thị thông tin để chúng ta tìm nguyên nhân.
Ghi chú:
1.lệnh chạy trực tiếp phpunit ngoài thư mục gốc phải gõ dấu \ mới chạy
vendor\bin\phpunit tests\Feature\BlogTest.php
2.nên chạy riêng BlogTest.php để tránh hiển thị quá nhiều
php artisan test tests/Feature/BlogTest.phpMột assertion rất thường dùng:
$response->assertOk();
Nó kiểm tra HTTP response có status:
200 OK
Ví dụ:
public function test_blog_page_is_accessible(): void
{
$response = $this->get('/blog');
$response->assertOk();
}
Có thể kiểm tra status cụ thể:
$response->assertStatus(200);
Ví dụ:
$response = $this->get('/blog');
$response->assertStatus(200);
Hoặc:
$response->assertStatus(404);
Ví dụ test một trang không tồn tại:
public function test_missing_page_returns_404(): void
{
$response = $this->get('/this-page-does-not-exist');
$response->assertStatus(404);
}
Nếu request phải redirect:
$response->assertRedirect('/login');
Ví dụ:
public function test_admin_page_requires_login(): void
{
$response = $this->get('/admin/users');
$response->assertRedirect('/login');
}
Đây là một test rất thực tế đối với Blog CMS.
Có thể kiểm tra response có chứa text:
$response->assertSee('Blog');
Ví dụ:
public function test_blog_contains_title(): void
{
$response = $this->get('/blog');
$response->assertOk();
$response->assertSee('Blog');
}
Hoặc:
$response->assertDontSee('Something');
Đây là phần đặc biệt quan trọng đối với Blog CMS.
API:
GET /api/posts
Test:
public function test_posts_api_returns_success(): void
{
$response = $this->getJson('/api/posts');
$response->assertStatus(200);
}
Hoặc:
$response->assertOk();
Giả sử API trả về:
{
"data": [
{
"id": 1,
"title": "Laravel 13"
}
]
}
Có thể kiểm tra:
$response->assertJson([
'data' => [
[
'id' => 1,
'title' => 'Laravel 13',
],
],
]);
Mục tiêu là kiểm tra API trả về đúng cấu trúc dữ liệu mong muốn.
Ghi chú: ví dụ trên chỉ đúng nếu trả về api/posts/1
Đối với trường hợp trả về nguyên mảng phải viết lại:
// test API danh sách posts
public function test_posts_api_returns_success(): void
{
$response = $this->getJson('/api/posts');
$response->assertStatus(200)
->assertJsonStructure([
'success',
'data' => [
'*' => [ // <--- Dấu * đại diện cho từng phần tử trong mảng data
'id',
'title',
'slug',
'excerpt',
'image_url',
'published_at',
'is_published',
'views',
'author' => ['id', 'name', 'email', 'role', 'created_at'],
'category' => ['id', 'name', 'slug']
]
]
]);
}
Feature Test có thể kiểm tra database.
Laravel cung cấp các trait hỗ trợ việc reset database giữa các test.
Ví dụ:
use Illuminate\Foundation\Testing\RefreshDatabase;
Sau đó:
class PostTest extends TestCase
{
use RefreshDatabase;
// ...
}
Mỗi test có thể làm việc với database testing thay vì phá dữ liệu development hiện tại.
Giả sử chúng ta có:
PostFactory
Có thể tạo Post:
$post = Post::factory()->create();
Sau đó test:
$response = $this->get('/blog/' . $post->slug);
$response->assertOk();
Đây là cách rất phù hợp với Blog CMS.
Trước khi chạy ví dụ dưới để tránh mất data phải:
.env.testing và kiểm tra xem laravel đang nhận database nào lúc chạy testphp artisan config:clear
php artisan env --env=testing
php artisan config:show database.default --env=testingNội dung file .env.testing
APP_ENV=testing
APP_KEY=base64:key
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=your_db_test
DB_USERNAME=root
DB_PASSWORD=
# Bắt buộc xóa hẳn hoặc để trống dòng DB_URL
DB_URL=Nhớ vào phpmyadmin tạo database có tên là: your_db_test
database này sẽ trống trơn do phpunit.xml cấu hình chạy trên RAM.
Data lúc này dựa vào Factory() hoặc $seed (tạo toàn bộ db -> khá nặng) như trường hợp BlogTest
Phải sửa trong PostFactory
// Khai báo mối quan hệ theo chuẩn Laravel
'user_id' => User::factory(),
'category_id' => Category::factory(),Ví dụ:
<?php
namespace Tests\Feature;
use App\Models\Category;
use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PostTest extends TestCase
{
use RefreshDatabase;
public function test_post_page_can_be_opened(): void
{
$user = User::factory()->create();
$category = Category::factory()->create();
// Ép status = published để route tìm thấy bài viết
$post = Post::factory()->create([
'user_id' => $user->id,
'category_id' => $category->id,
'status' => 'published',
'published_at' => now(),
]);
$response = $this->get(
'/blog/' . $post->slug
);
$response->assertOk();
}
}
Flow:
Factory
↓
Create Post
↓
Request URL
↓
Laravel
↓
Response
↓
assertOk()
Ví dụ một trang admin yêu cầu đăng nhập.
public function test_admin_requires_authentication(): void
{
$response = $this->get('/admin/users');
$response->assertRedirect('/login');
}
Sau đó test user đã đăng nhập (đôi khi user thường k cho vào phải role admin):
//$user = User::factory()->create();
$user = User::factory()->create(['role' => 'admin']);
$this->actingAs($user);
$response = $this->get('/admin/users');
// Đã đăng nhập -> Phải trả về 200 OK thành công
$response->assertOk();
Đây là cách kiểm tra các route có authentication.
Authentication chưa đủ.
Chúng ta còn phải kiểm tra quyền.
Ví dụ:
user
admin
Một user thông thường không được phép truy cập:
/admin/users
Test có thể kiểm tra:
$user = User::factory()->create([
'role' => 'user',
]);
$this->actingAs($user);
$response = $this->get('/admin/users');
$response->assertForbidden();
Trong trường hợp ứng dụng của bạn trả về redirect hoặc response khác tùy middleware/policy, assertion phải phù hợp với implementation thực tế.
Điểm quan trọng:
Phải test cả Authentication và Authorization.
Ví dụ tạo Post yêu cầu:
title
content
category_id
Nếu gửi request thiếu title:
$response = $this->post('/admin/posts', [
'content' => 'Test content',
]);
Có thể kiểm tra validation:
$response->assertSessionHasErrors([
'title',
]);
Đây là một test rất hữu ích.
Full code:
/**
* Test validation khi tạo bài viết mới thiếu các trường bắt buộc.
*/
public function test_create_post_validation_fails_when_required_fields_are_missing(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$this->actingAs($admin);
// Gửi request rỗng
$response = $this->post('/admin/posts', []);
// Kiểm tra đúng 4 trường bắt buộc (có chữ required)
$response->assertSessionHasErrors([
'title',
'slug',
'content',
'status',
]);
$response->assertStatus(302);
}Blog CMS của chúng ta có:
GET
POST
PUT/PATCH
DELETE
Do đó có thể xây dựng một bộ test:
Post API
│
├── GET /api/posts
├── GET /api/posts/{id}
├── POST /api/posts
├── PUT /api/posts/{id}
└── DELETE /api/posts/{id}
Ví dụ:
public function test_posts_api_returns_json(): void
{
$response = $this->getJson('/api/posts');
$response->assertOk();
$response->assertJsonStructure([
'data',
]);
}
API private yêu cầu:
Bearer Token
Có thể tạo user:
$user = User::factory()->create();
Tạo token:
$token = $user->createToken('testing')->plainTextToken;
Sau đó request:
$response = $this
->withToken($token)
->getJson('/api/user');
Kiểm tra:
$response->assertOk();
Test request không có token:
public function test_private_api_requires_authentication(): void
{
$response = $this->getJson('/api/user');
$response->assertUnauthorized();
}
Kết quả mong muốn:
HTTP 401
Sau đó test với token hợp lệ:
$user = User::factory()->create();
$token = $user
->createToken('testing')
->plainTextToken;
$response = $this
->withToken($token)
->getJson('/api/user');
$response->assertOk();
Trong Blog CMS, chúng ta đã học Event và Listener.
Testing cũng có thể kiểm tra Event có được dispatch hay không.
Trước tiên vào app/Events/ xem có event nào hoặc dùng lệnh:
php artisan event:listVí dụ: PostPublished và UserRegistered (đã học trong bài Event)
Sau đó vào PostController xem hàm store dòng:
event(new PostPublished($post));Laravel cung cấp:
Event::fake();
Sau đó chạy code cần kiểm tra.
Cuối cùng:
Event::assertDispatched(
Registered::class
);
Laravel hỗ trợ Event::fake() và các assertion như assertDispatched() để kiểm tra event mà không cần thực thi listener trong test đó.
Flow:
Test
│
▼
Event::fake()
│
▼
Execute Code
│
▼
Event dispatched?
│
├── YES → PASS
└── NO → FAIL
Dưới đây là full code passed
use App\Events\PostPublished;
use App\Events\UserRegistered;
use App\Models\Post;
use App\Models\User;
use App\Models\Category;
use Illuminate\Support\Facades\Event;
function test_user_registered_event_is_dispatched(): void
{
Event::fake();
$user = User::factory()->create();
event(new UserRegistered($user));
Event::assertDispatched(
UserRegistered::class
);
}
//test event published_post bắn ra khi store post thành công chọn published
public function test_creating_published_post_dispatches_event(): void
{
Event::fake();
$admin = User::factory()->create([
'role' => 'admin',
]);
$category = Category::factory()->create();
$this->actingAs($admin);
$response = $this->post('/admin/posts', [
'category_id' => $category->id,
'title' => 'Laravel Testing',
'slug' => 'laravel-testing',
'content' => str_repeat('Nội dung test ', 5),
'status' => 'published',
]);
Event::assertDispatched(
PostPublished::class,
fn (PostPublished $event)
=> $event->post->title === 'Laravel Testing'
);
}
//test event published_post k bắn ra khi draft
public function test_creating_draft_post_does_not_dispatch_event(): void
{
Event::fake();
$admin = User::factory()->create([
'role' => 'admin',
]);
$category = Category::factory()->create();
$this->actingAs($admin);
$this->post('/admin/posts', [
'category_id' => $category->id,
'title' => 'Draft Post',
'slug' => 'draft-post',
'content' => str_repeat('Nội dung test ', 5),
'status' => 'draft',
]);
Event::assertNotDispatched(
PostPublished::class
);
}
Tương tự Event, Queue cũng có thể được fake.
Ví dụ:
Queue::fake();
Sau đó:
SomeJob::dispatch();
Kiểm tra:
Queue::assertPushed(SomeJob::class);
Mục tiêu:
Kiểm tra Job đã được dispatch hay chưa.
Không nhất thiết phải thực sự chạy Job trong test này.
use App\Jobs\SendWelcomeEmail;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
public function test_welcome_email_job_is_queued(): void
{
Queue::fake();
$user = User::factory()->create();
SendWelcomeEmail::dispatch($user);
Queue::assertPushed(
SendWelcomeEmail::class
);
}Mail cũng có thể fake.
Ví dụ:
Mail::fake();
Sau đó:
Mail::to($user)->send(
new WelcomeMail($user)
);
Kiểm tra:
Mail::assertSent(WelcomeMail::class);
Điều này đặc biệt hữu ích với:
Register
Password Reset
Contact Form
Notification
Order Confirmation
Full Code MailTest:
//php artisan make:test MailTest
namespace Tests\Feature;
use Tests\TestCase;
use Illuminate\Support\Facades\Mail;
use App\Mail\WelcomeUser;
//php artisan test tests/Feature/MailTest.php
class MailTest extends TestCase
{
public function test_user_receives_welcome_email()
{
Mail::fake();
Mail::to('test@example.com')
->send(new WelcomeUser('Nguyễn Văn A'));
Mail::assertSent(WelcomeUser::class, function ($mail) {
return $mail->hasTo('test@example.com')
&& $mail->name === 'Nguyễn Văn A';
});
}
}
//END class
Blog CMS có upload image.
Có thể fake Storage:
Storage::fake('public');
Sau đó:
$file = UploadedFile::fake()
->image('post.jpg');
Gửi request:
$response = $this->post('/admin/posts', [
'title' => 'Laravel 13',
'image' => $file,
]);
Kiểm tra file:
Storage::disk('public')->assertExists(
'posts/' . $file->hashName()
);
Đây là cách test upload mà không cần ghi file thật vào storage production.
Full code UploadTest:
// php artisan make:test UploadTest
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
use App\Models\User;
use App\Models\Country;
// php artisan test tests/Feature/UploadTest.php
class UploadTest extends TestCase
{
use RefreshDatabase;
public function test_image_can_be_uploaded()
{
Storage::fake('public');
$user = User::factory()->create([
'role' => 'admin',
]);
$file = UploadedFile::fake()
->image('post.jpg');
$response = $this->actingAs($user)
->post('/admin/posts', [
'title' => 'Laravel 13',
'slug' => 'laravel-13',
'content' => str_repeat('Nội dung test ', 5),
'status' => 'draft',
'image' => $file,
]);
$response->assertRedirect();
Storage::disk('public')->assertExists(
'posts/' . $file->hashName()
);
}
}
//END classBlog CMS có Soft Delete.
Có thể kiểm tra:
$post = Post::factory()->create();
$post->delete();
Sau đó:
$this->assertSoftDeleted($post);
Hoặc kiểm tra record đã bị đánh dấu:
deleted_at != NULL
Sau đó test restore:
$post->restore();
Full code SoftDeleteTest:
// php artisan make:test SoftDeleteTest
namespace Tests\Feature;
use App\Models\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
// php artisan test tests/Feature/SoftDeleteTest.php
class SoftDeleteTest extends TestCase
{
use RefreshDatabase;
public function test_post_can_be_soft_deleted()
{
$post = Post::factory()->create();
$post->delete();
$this->assertSoftDeleted($post);
}
public function test_deleted_at_is_not_null_after_delete()
{
$post = Post::factory()->create();
$post->delete();
$this->assertNotNull(
$post->fresh()->deleted_at
);
}
public function test_post_can_be_restored()
{
$post = Post::factory()->create();
$post->delete();
$this->assertSoftDeleted($post);
$post->restore();
$this->assertNull(
$post->fresh()->deleted_at
);
}
}
Ví dụ Post:
Create
Read
Update
Delete
Chúng ta có thể xây dựng:
PostTest
│
├── can list posts
├── can show post
├── can create post
├── can update post
├── can delete post
└── can restore post
Đây chính là cách biến test thành một bộ kiểm tra tự động cho CMS.
<?php
// php artisan make:test CRUDPostTest
namespace Tests\Feature;
use App\Models\Post;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
// php artisan test --filter=CRUDPostTest
class CRUDPostTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected function setUp(): void
{
parent::setUp();
// Tạo Admin User và authenticate toàn bộ request trong class
$this->user = User::factory()->create([
'role' => 'admin',
]);
$this->actingAs($this->user);
}
/**
* Test 1: Hiển thị danh sách bài viết (Index)
*/
public function test_can_list_posts(): void
{
Post::factory()->count(5)->create([
'status' => 'published',
]);
$response = $this->get(route('admin.posts.index'));
$response->assertStatus(200);
}
/**
* Test 2: Xem chi tiết bài viết (Show)
*/
public function test_can_show_post(): void
{
$post = Post::factory()->create([
'title' => 'Bài viết thử nghiệm',
'slug' => 'bai-viet-thu-nghiem',
'content' => 'Nội dung chi tiết bài viết',
'status' => 'published',
]);
$response = $this->get(route('admin.posts.show', $post));
$response->assertStatus(200);
}
/**
* Test 3: Tạo mới bài viết (Store)
*/
public function test_can_create_post(): void
{
// Đảm bảo gửi ĐỦ dữ liệu theo đúng validation của validatePost()
$payload = [
'title' => 'Tựa đề bài viết mới',
'slug' => 'tua-de-bai-viet-moi-test',
'content' => 'Nội dung bài viết tạo từ test case',
'status' => 'draft',
'user_id' => $this->user->id,
'image' => UploadedFile::fake()->image('post.jpg'), // Giả lập file ảnh thật
];
$response = $this->post(route('admin.posts.store'), $payload);
// Bắt buộc check không dính lỗi Validation
//$response->assertSessionHasNoErrors();
$response->assertSessionHasNoErrors()
->assertRedirect(route('admin.posts.show', 1));
$this->assertDatabaseHas('posts', [
'title' => 'Tựa đề bài viết mới',
'slug' => 'tua-de-bai-viet-moi-test',
'status' => 'draft',
]);
}
/**
* Test 4: Cập nhật bài viết (Update)
*/
public function test_can_update_post(): void
{
// Tạo post ban đầu có user_id thuộc về $this->user
$post = Post::factory()->create([
'user_id' => $this->user->id,
'title' => 'Tựa đề cũ',
'slug' => 'tua-de-cu-test',
'content' => 'Nội dung cũ',
'status' => 'draft',
]);
$payload = [
'title' => 'Tựa đề đã cập nhật',
'slug' => 'tua-de-cu-test', // Giữ nguyên slug cũ để pass rule unique:posts,slug,{id}
'content' => 'Nội dung đã được chỉnh sửa mới',
'status' => 'published',
'user_id' => $this->user->id,
];
$response = $this->put(route('admin.posts.update', $post), $payload);
//$response->assertSessionHasNoErrors();
$response->assertSessionHasNoErrors()
->assertRedirect(route('admin.posts.show', 1));
$this->assertDatabaseHas('posts', [
'id' => $post->id,
'title' => 'Tựa đề đã cập nhật',
'status' => 'published',
]);
}
/**
* Test 5: Xóa mềm bài viết (Destroy)
*/
public function test_can_delete_post(): void
{
$post = Post::factory()->create([
'user_id' => $this->user->id,
'status' => 'published',
]);
$response = $this->delete(route('admin.posts.destroy', $post));
//$response->assertSessionHasNoErrors();
$response->assertSessionHasNoErrors()
->assertRedirect(route('admin.posts.index'));
$this->assertSoftDeleted('posts', [
'id' => $post->id,
]);
}
/**
* Test 6: Khôi phục bài viết đã xóa mềm (Restore - Route PATCH)
*/
public function test_can_restore_post(): void
{
$post = Post::factory()->create([
'status' => 'published',
]);
$post->delete();
$this->assertSoftDeleted('posts', [
'id' => $post->id,
]);
// Dùng URL trực tiếp /admin/posts/{id}/restore với PATCH để tránh bị route model binding chặn record đã soft delete
$response = $this->patch(route('admin.posts.restore', $post->id));
$this->assertNotSoftDeleted('posts', [
'id' => $post->id,
]);
}
}
//END classMột test đơn giản:
<?php
namespace Tests\Feature;
use App\Models\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class BlogTest extends TestCase
{
use RefreshDatabase;
public function test_blog_page_is_accessible(): void
{
$response = $this->get('/blog');
$response->assertOk();
}
public function test_post_can_be_viewed(): void
{
$post = Post::factory()->create();
$response = $this->get(
'/blog/' . $post->slug
);
$response->assertOk();
}
}
Chúng ta có thể tiếp tục mở rộng thành:
BlogTest
PostTest
CategoryTest
UserTest
ApiTest
AuthenticationTest
Với API Resource, đây là một test rất đáng làm:
$response->assertJsonStructure([
'data' => [
'*' => [
'id',
'title',
'slug',
],
],
]);
Nếu sau này developer vô tình xóa slug khỏi Resource:
Test FAIL
Nhờ vậy chúng ta phát hiện thay đổi API trước khi frontend hoặc mobile app gặp lỗi.
Một cách hiểu tốt hơn:
Test là một lớp bảo vệ cho source code.
Ví dụ:
Hôm nay
↓
Code chạy đúng
↓
Viết Test
↓
3 tháng sau
↓
Sửa Controller
↓
Chạy Test
↓
PASS
Nếu:
FAIL
chúng ta biết thay đổi mới có thể đã ảnh hưởng chức năng cũ.
Đây gọi là Regression Testing.
Ví dụ:
Version 1
↓
Login hoạt động
↓
Version 2
↓
Thêm API
↓
Version 3
↓
Sửa User
↓
Login bị lỗi
Nếu có test:
php artisan test
chúng ta có thể phát hiện lỗi nhanh hơn.
Khi chạy test, Laravel sử dụng environment:
testing
Các thiết lập testing có thể được cấu hình trong phpunit.xml hoặc .env.testing. Laravel cũng cấu hình một số driver phù hợp cho testing, chẳng hạn session và cache mặc định không ghi dữ liệu vào hệ thống thật.
Có thể tạo:
.env.testing
Ví dụ:
APP_ENV=testing
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
Tùy cấu hình project, bạn cũng có thể sử dụng một database testing riêng.
Điểm quan trọng:
Không chạy test trên database production.
Laravel project có:
phpunit.xml
File này chứa cấu hình dành cho PHPUnit.
Ví dụ có thể cấu hình:
<php>
<env name="APP_ENV" value="testing"/>
<env name="CACHE_STORE" value="array"/>
</php>
Không nên tùy tiện sửa cấu hình nếu chưa hiểu tác động của nó.
Ghi chú: chỉnh sửa trong đây phải clear config.
Không cần chạy toàn bộ test suite.
Có thể chỉ chạy:
php artisan test --filter=BlogTest
Hoặc:
vendor/bin/phpunit --filter BlogTest
Điều này rất hữu ích khi đang phát triển một module.
Khi xây dựng một feature:
Requirement
↓
Code
↓
Test
↓
Run Test
↓
PASS?
┌──┴──┐
YES NO
│ │
▼ ▼
Done Debug
│
▼
Fix
│
└──────► Test lại
Đây là vòng lặp rất quan trọng trong phát triển phần mềm.
Với project cuối khóa, không cần test mọi dòng code.
Có thể ưu tiên các chức năng quan trọng:
Authentication
↓
Authorization
↓
Categories
↓
Posts
↓
Upload
↓
Soft Delete
↓
REST API
↓
Sanctum
Ví dụ:
tests/
│
├── Feature/
│ ├── AuthenticationTest.php
│ ├── CategoryTest.php
│ ├── PostTest.php
│ ├── UserTest.php
│ └── ApiTest.php
│
└── Unit/
└── ExampleTest.php
Không cần biến project Blog CMS thành một hệ thống test khổng lồ.
Một người mới rất dễ nghĩ:
"Phải test 100% code."
Không nhất thiết.
100% coverage không đồng nghĩa:
100% không có bug
Điều quan trọng hơn là test những behavior quan trọng.
Ví dụ Blog CMS:
User không đăng nhập
→ không vào Admin
User thường
→ không quản lý User
Admin
→ quản lý User
Post draft
→ không xuất hiện public
Post published
→ xuất hiện Blog
API private
→ yêu cầu token
Upload sai file
→ validation fail
Đây là những behavior có giá trị cao để kiểm thử.
Khi đã có test, sau này có thể đưa vào CI/CD:
Git Push
│
▼
CI Server
│
├── composer install
├── Run Tests
├── Build Assets
└── Deploy
Nếu:
Tests PASS
→ có thể tiếp tục deployment.
Nếu:
Tests FAIL
→ dừng deployment.
Đây là bước phát triển tiếp theo sau khi đã nắm testing cơ bản.
Một số lệnh và assertion cần nhớ:
php artisan test
php artisan test --filter=BlogTest
$response->assertOk();
$response->assertStatus(200);
$response->assertRedirect('/login');
$response->assertSee('Blog');
$response->assertSessionHasErrors([
'title',
]);
$response->assertJson([
'data' => [],
]);
$response->assertJsonStructure([
'data',
]);
$response->assertUnauthorized();
$response->assertForbidden();
Tạo:
tests/Feature/BlogTest.php
Test:
GET /blog
Yêu cầu:
HTTP 200
Test Post:
GET /blog/{slug}
Yêu cầu:
HTTP 200
với một Post hợp lệ.
Test validation khi tạo Post.
Thiếu:
title
Kết quả:
validation error
Test API:
GET /api/posts
Yêu cầu:
HTTP 200
JSON
data
Test API private:
GET /api/user
Không có token:
401
Có token:
200
Test Upload Image.
Kiểm tra:
image hợp lệ
→ upload thành công
file không hợp lệ
→ validation fail
Không nhất thiết phải chờ project hoàn thành.
Có thể viết test ngay khi hoàn thành một feature:
Category
↓
Code
↓
Test
Post
↓
Code
↓
Test
API
↓
Code
↓
Test
Cách này giúp lỗi được phát hiện sớm.
Một workflow đơn giản:
1. Nhận requirement
↓
2. Thiết kế
↓
3. Code
↓
4. Viết Test
↓
5. Run Test
↓
6. Fix Bug
↓
7. Code Review
↓
8. Deploy
Ở project lớn hơn:
Developer
↓
Git
↓
CI
↓
Tests
↓
Build
↓
Deploy
Sau Bonus này, người học cần hiểu được:
PHPUnit
│
├── Unit Test
│
├── Feature Test
│
├── HTTP Test
│
├── Database Test
│
├── API Test
│
├── Authentication Test
│
├── Validation Test
│
├── Upload Test
│
└── Event / Queue / Mail Test
Và những assertion cơ bản:
assertOk()
assertStatus()
assertRedirect()
assertSee()
assertSessionHasErrors()
assertJson()
assertJsonStructure()
assertUnauthorized()
assertForbidden()Testing không phải là việc viết thật nhiều code test.
Điều quan trọng là xác định:
Ứng dụng phải làm gì?
Sau đó biến yêu cầu đó thành test.
Ví dụ:
Requirement:
"User chưa đăng nhập không được vào Admin."
↓
Test:
GET /admin/users
↓
Expected:
401 / Redirect Login
Hoặc:
Requirement:
"API Posts phải trả JSON."
↓
Test:
GET /api/posts
↓
Expected:
200
JSON
data
Hoặc:
Requirement:
"User thường không được quản lý User."
↓
Test:
User → GET /admin/users
↓
Expected:
403
Đó chính là tư duy testing.
x1
Cập nhật: 2026-10-07T20:22:08.528+07:00
Ở các bài trước, chúng ta đã lần lượt xây dựng và triển khai Blog CMS:
Bài 45 → Shared Hosting
Bài 46 → VPS
Bài 47 → Nginx
Bài 48 → Queue Worker & Scheduler
Đến đây ứng dụng đã có thể chạy trên production.
Nhưng:
Chạy được chưa có nghĩa là đã được cấu hình tốt cho production.
Một Laravel application thực tế cần quan tâm thêm:
Performance
Cache
Environment
Debug
Security
Database
Queue
Storage
Logs
Deployment
Configuration
Laravel 13 cung cấp sẵn nhiều công cụ để tối ưu những vấn đề này. Trong tài liệu deployment chính thức, Laravel đặc biệt khuyến nghị cache configuration, events, routes và views khi triển khai production.
Trong quá trình học, chúng ta thường chạy:
php artisan serve
với:
APP_ENV=local
APP_DEBUG=true
Điều này phù hợp cho development.
Production nên sử dụng:
APP_ENV=production
APP_DEBUG=false
Mô hình:
Development
APP_ENV=local
APP_DEBUG=true
│
▼
Debug nhiều
Hot reload
Test
Development
Production:
Production
APP_ENV=production
APP_DEBUG=false
│
▼
Security
Performance
Caching
Monitoring
.envLaravel sử dụng .env để chứa các giá trị phụ thuộc môi trường.
Ví dụ:
APP_NAME="Blog CMS"
APP_ENV=production
APP_KEY=base64:...
APP_DEBUG=false
APP_URL=https://example.com
Database:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=blog
DB_USERNAME=blog_user
DB_PASSWORD=********
Mail:
MAIL_MAILER=smtp
MAIL_HOST=smtp.example.com
MAIL_PORT=587
MAIL_USERNAME=...
MAIL_PASSWORD=...
MAIL_ENCRYPTION=tls
Queue:
QUEUE_CONNECTION=database
Cache:
CACHE_STORE=database
Các giá trị này có thể thay đổi giữa:
Local
Staging
Production
Laravel cũng khuyến cáo không commit .env vào source control vì file này có thể chứa credentials và thông tin nhạy cảm.
.env không phải file để đưa lên GitKhông nên:
git add .env
Thay vào đó:
.env
nên được ignore.
Thông thường project có:
.env
.env.example
Trong đó:
.envChứa cấu hình thực tế:
DB_PASSWORD=secret-password
.env.exampleChỉ chứa cấu trúc mẫu:
DB_PASSWORD=
Nhờ vậy người khác có thể biết application cần những biến môi trường nào mà không biết password production.
Ví dụ:
APP_ENV=production
Laravel có thể kiểm tra environment:
use Illuminate\Support\Facades\App;
if (App::environment('production')) {
// Production
}
Hoặc:
if (App::environment(['local', 'staging'])) {
// Local hoặc staging
}
Trong production Blog CMS:
APP_ENV=production
là lựa chọn phù hợp.
Đây là một trong những biến quan trọng nhất:
APP_DEBUG=false
Development:
APP_DEBUG=true
Production:
APP_DEBUG=false
Laravel documentation cảnh báo APP_DEBUG=true trên production có thể làm lộ thông tin nhạy cảm cho người dùng.
Giả sử code có lỗi:
$post->category->name;
nhưng:
$post->category
là null.
Development có thể hiển thị rất nhiều thông tin:
Exception
File
Line
Stack Trace
Environment
Configuration
SQL
Request
Điều này cực kỳ hữu ích khi debug.
Nhưng production:
APP_DEBUG=true
có thể biến thông tin debug thành dữ liệu mà attacker có thể khai thác.
Do đó:
APP_DEBUG=false
Production nên hiển thị một thông báo thân thiện:
Something went wrong.
Please try again later.
thay vì:
SQLSTATE...
/var/www/blog/vendor/...
DB_PASSWORD...
Stack trace...
Nguyên tắc:
Development
↓
Developer cần thông tin
Production
↓
User cần thông báo an toàn
Laravel application cần:
APP_KEY=base64:...
Không được tùy tiện thay đổi APP_KEY trên production đang chạy.
Nếu thay đổi key, những dữ liệu được mã hóa bằng key cũ có thể không còn giải mã được.
Ví dụ:
php artisan key:generate
Chỉ nên thực hiện khi tạo application hoặc khi bạn hiểu rõ tác động.
Không nên mỗi lần deploy lại chạy:
php artisan key:generate
Production nên đặt URL thật:
APP_URL=https://example.com
Không nên để:
APP_URL=http://127.0.0.1:8000
nếu application đang chạy trên Internet.
APP_URL có thể được sử dụng trong nhiều tình huống tạo URL, command và các thành phần khác của application.
Cache là kỹ thuật lưu lại dữ liệu hoặc kết quả đã được xử lý để lần sau lấy nhanh hơn.
Ví dụ:
Không Cache
Request
↓
Database
↓
Query
↓
Process
↓
Response
Có Cache:
Request
↓
Cache
│
├── Có → Response nhanh
│
└── Không
↓
Database
↓
Cache
↓
Response
Laravel cung cấp API thống nhất cho nhiều cache backend.
Ví dụ:
use Illuminate\Support\Facades\Cache;
Lưu:
Cache::put('site_name', 'Blog CMS', 3600);
Lấy:
$name = Cache::get('site_name');
Hoặc:
$name = Cache::get('site_name', 'Default');
Xóa:
Cache::forget('site_name');
Cache::remember()Đây là một pattern rất hữu ích.
Ví dụ:
$categories = Cache::remember(
'blog.categories',
3600,
function () {
return Category::active()
->withCount('posts')
->orderBy('name')
->get();
}
);
Luồng:
Request
│
▼
Cache?
│
├── Có
│ ↓
│ Return
│
└── Không
↓
Database
↓
Cache
↓
Return
Thay vì query database liên tục, dữ liệu có thể được sử dụng từ cache trong thời gian đã cấu hình.
Trong Blog CMS, chúng ta có:
Categories
Tags
Settings
Statistics
Popular Posts
Homepage Data
Một số dữ liệu không thay đổi liên tục có thể phù hợp với cache.
Ví dụ:
$categories = Cache::remember(
'categories.active',
3600,
fn () => Category::active()
->withCount('posts')
->orderBy('name')
->get()
);
Cache không phải:
Càng nhiều càng tốt.
Ví dụ:
User profile
Admin permissions
Realtime stock price
Current order status
có thể cần dữ liệu mới.
Nếu cache quá lâu:
Database
↓
Data mới
Cache
↓
Data cũ
Người dùng có thể nhìn thấy dữ liệu stale.
Do đó cần cân nhắc:
Data thay đổi thường xuyên
→ Cache ngắn hoặc không cache
Data ít thay đổi
→ Cache lâu hơn
Trong development hoặc khi troubleshooting:
php artisan optimize:clear
Laravel 13 cung cấp optimize:clear để xóa các cache được tạo bởi optimize và các key trong default cache store.
Có thể sử dụng riêng:
php artisan config:clear
hoặc:
php artisan route:clear
hoặc:
php artisan view:clear
Tùy trường hợp.
Laravel 13 có command:
php artisan optimize
Đây là command rất quan trọng khi deploy production.
Laravel sẽ cache các thành phần quan trọng như:
Configuration
Events
Routes
Views
Laravel documentation khuyến nghị chạy php artisan optimize trong deployment process.
config:cacheChạy:
php artisan config:cache
Laravel gom configuration vào cache.
Điều này giúp giảm việc phải đọc nhiều file configuration khi application boot.
env()Sau khi:
php artisan config:cache
không nên viết:
$name = env('APP_NAME');
trực tiếp trong application code.
Không nên:
class BlogController
{
public function index()
{
$debug = env('APP_DEBUG');
}
}
Thay vào đó:
$debug = config('app.debug');
Tư duy đúng:
.env
↓
config/*.php
↓
config(...)
Ví dụ trong:
config/app.php
có:
'debug' => (bool) env('APP_DEBUG', false),
Application sử dụng:
config('app.debug');
Laravel documentation lưu ý rằng sau khi config:cache, .env không còn được load trong request và env() bên ngoài configuration files có thể trả về null.
Laravel có:
php artisan route:cache
Command này tạo route cache.
Đặc biệt có lợi với application có nhiều routes.
Blog CMS của chúng ta có thể có:
Web Routes
Admin Routes
Blog Routes
API Routes
Auth Routes
Khi số lượng route tăng lên, route caching giúp giảm thời gian đăng ký routes khi application boot.
Chạy:
php artisan view:cache
Laravel sẽ precompile Blade views.
Ví dụ:
resources/views/
↓
Blade
↓
Compiled Views
Nhờ vậy production không phải compile từng Blade view lần đầu khi request.
Laravel khuyến nghị cache views trong deployment process.
Laravel cũng hỗ trợ:
php artisan event:cache
Command này cache event → listener mappings.
Ví dụ Blog CMS:
Registered
↓
SendWelcomeEmail
PostPublished
↓
SendNotification
PostUpdated
↓
ClearPostCache
Khi application lớn, caching event discovery có thể giúp quá trình boot hiệu quả hơn.
Có thể chạy:
php artisan optimize
Thay vì phải chạy riêng:
php artisan config:cache
php artisan event:cache
php artisan route:cache
php artisan view:cache
Laravel 13 cung cấp optimize như command tổng hợp cho deployment.
Khi deploy production:
composer install \
--no-dev \
--optimize-autoloader
Trong một dòng:
composer install --no-dev --optimize-autoloader
Ý nghĩa:
--no-dev
Không cài các package development không cần thiết.
Ví dụ:
PHPUnit
Debugbar
Development tools
--optimize-autoloader giúp Composer tối ưu class autoloading.
npm run dev trên productionDevelopment:
npm run dev
Production:
npm run build
Ví dụ:
npm install
npm run build
Sau đó:
public/build
chứa production assets.
Nếu Blog CMS sử dụng Vite:
resources/css
resources/js
thì production nên build:
npm run build
Thay vì để development server chạy trên production.
Mô hình:
Development
↓
npm run dev
Production
↓
npm run build
Một trong những bottleneck lớn nhất của web application thường là database.
Ví dụ:
Post::all();
có thể lấy quá nhiều dữ liệu.
Không nên:
$posts = Post::all();
nếu database có:
1,000,000 posts
Thay vào đó:
$posts = Post::paginate(10);
Blog CMS của chúng ta đã sử dụng:
->paginate(10)
Ví dụ:
$posts = Post::published()
->latest('published_at')
->paginate(10);
Thay vì lấy toàn bộ:
Post::all();
Database chỉ phải xử lý lượng dữ liệu phù hợp với page hiện tại.
Một lỗi rất phổ biến:
$posts = Post::all();
foreach ($posts as $post) {
echo $post->user->name;
echo $post->category->name;
}
Có thể tạo ra vấn đề N+1 Query.
Thay vào đó:
$posts = Post::with([
'user',
'category'
])->get();
Laravel sẽ eager load relationship.
Controller của chúng ta đã sử dụng:
Post::published()
->with(['user', 'category'])
->latest('published_at')
->paginate(10);
Đây là cách tốt hơn:
Posts
│
├── User
└── Category
thay vì để từng Post tự query User và Category.
Không phải lúc nào cũng cần:
Post::all();
với toàn bộ column.
Ví dụ:
Post::query()
->select([
'id',
'title',
'slug',
'excerpt'
])
->latest()
->paginate(10);
Nếu page chỉ hiển thị:
Title
Excerpt
thì không nhất thiết phải lấy toàn bộ content lớn.
Các column thường được:
WHERE
ORDER BY
JOIN
có thể cần index phù hợp.
Ví dụ:
posts.slug
posts.user_id
posts.category_id
posts.status
posts.published_at
Migration:
$table->index('status');
$table->index('published_at');
Foreign key thường đã có index phù hợp tùy schema và cách khai báo, nhưng cần hiểu execution plan và workload thực tế thay vì index mọi column.
Sai lầm:
Index tất cả
Index giúp query:
Nhanh hơn
nhưng cũng có cost:
INSERT
UPDATE
DELETE
phải duy trì index.
Do đó:
Query thường xuyên
↓
Phân tích
↓
Index phù hợp
Không phải:
Column nào cũng index
Không nên cache mọi query.
Ví dụ categories:
$categories = Cache::remember(
'categories.active',
3600,
fn () => Category::active()
->withCount('posts')
->orderBy('name')
->get()
);
Khi category thay đổi:
Cache::forget('categories.active');
Như vậy cache có thể được invalidated đúng thời điểm.
Một trong những vấn đề khó của cache là:
Cache cũ
Ví dụ:
10:00
Category = Laravel
cache:
Laravel
11:00 admin đổi:
Laravel → Laravel 13
nhưng cache vẫn:
Laravel
Nếu không clear/invalidate:
Database = Laravel 13
Cache = Laravel
Vì vậy khi update dữ liệu quan trọng:
Cache::forget('categories.active');
hoặc sử dụng strategy phù hợp.
Production cần kiểm tra:
php artisan storage:link
Laravel sử dụng:
storage/app/public
cho public files và symbolic link:
public/storage
để browser có thể truy cập.
Blog CMS:
Post Image
↓
storage/app/public/posts
↓
public/storage
↓
Browser
Laravel cần quyền ghi cho:
storage/
bootstrap/cache/
Ví dụ trên Ubuntu + Nginx/PHP-FPM:
sudo chown -R www-data:www-data /var/www/blog/storage
sudo chown -R www-data:www-data /var/www/blog/bootstrap/cache
Tùy server setup, user chạy PHP-FPM có thể khác www-data.
Không nên dùng:
chmod -R 777
chỉ để “cho chạy được”.
chmod 777 nguy hiểm?chmod -R 777 /var/www/blog
nghĩa là:
Owner → read/write/execute
Group → read/write/execute
Others → read/write/execute
Đây là quyền quá rộng.
Thay vào đó:
Owner
Group
Web Server
nên được cấu hình đúng.
Nguyên tắc:
Cấp quyền tối thiểu cần thiết.
.envKhông bao giờ public:
.env
Nginx phải trỏ:
root /var/www/blog/public;
chứ không:
root /var/www/blog;
Laravel deployment documentation nhấn mạnh rằng web server phải hướng request tới public/index.php, không phải project root, để tránh expose sensitive files.
Production:
APP_DEBUG=false
Không:
APP_DEBUG=true
Đây là một trong những cấu hình bảo mật cơ bản nhất của Laravel production.
Production:
APP_ENV=production
Giúp application biết mình đang chạy trong environment nào.
Các thông tin như:
APP_KEY
DB_PASSWORD
MAIL_PASSWORD
AWS_SECRET
API_KEY
không nên hard-code vào source:
$password = '123456';
Không nên:
Git
↓
Source
↓
Secret
Mà:
Environment
↓
.env
↓
config
↓
Application
Laravel không nên lưu password dạng plain text.
Không:
password = 123456
Laravel sử dụng hashing.
Ví dụ:
use Illuminate\Support\Facades\Hash;
$hash = Hash::make($password);
Kiểm tra:
Hash::check($password, $hash);
Không tin dữ liệu từ browser.
Ví dụ:
$request->validate([
'title' => ['required', 'string', 'max:255'],
'content' => ['required', 'string'],
]);
Client-side validation:
JavaScript
chỉ giúp UX.
Server-side validation:
Laravel
mới là lớp bảo vệ thực sự.
Model:
protected $fillable = [
'name',
'slug',
'description',
'is_active',
];
Không nên cho phép user tùy tiện gửi:
role=admin
nếu role không thuộc dữ liệu họ được phép thay đổi.
Ví dụ:
User::create($request->validated());
cần kết hợp:
Validation
+
Fillable
+
Authorization
Authentication:
Bạn là ai?
Authorization:
Bạn được phép làm gì?
Ví dụ:
User
└── Xem bài viết
Admin
├── Tạo category
├── Sửa category
├── Xóa category
└── Quản lý user
Không nên chỉ dựa vào:
ẩn button
mà phải bảo vệ ở server bằng:
Middleware
Policy
Gate
Authorization
Các form web thay đổi dữ liệu cần CSRF protection.
Ví dụ Blade:
<form method="POST" action="{{ route('posts.store') }}">
@csrf
...
</form>
Laravel có cơ chế CSRF protection cho web requests.
Không nên bỏ:
@csrf
chỉ vì form vẫn submit được trong một số trường hợp.
Không nên nối chuỗi SQL trực tiếp:
DB::select(
"SELECT * FROM posts WHERE title = '$title'"
);
Thay vào đó sử dụng Query Builder/Eloquent:
Post::where('title', $title)->get();
Laravel sẽ sử dụng parameter binding phù hợp.
Nếu nội dung user nhập:
<script>...</script>
không nên tùy tiện render raw HTML.
Blade mặc định:
{{ $post->title }}
sẽ escape output.
Cẩn thận với:
{!! $post->content !!}
Nếu content cho phép HTML, cần có chiến lược sanitization phù hợp.
Blog CMS của chúng ta có upload image.
Không nên chỉ kiểm tra:
filename.jpg
Mà phải validation:
'image' => [
'nullable',
'image',
'max:2048',
]
Có thể giới hạn MIME/types theo yêu cầu application.
Ngoài server validation, frontend có thể báo:
File > 2MB
để cải thiện UX.
Nhưng validation Laravel vẫn bắt buộc.
Không nên lưu file upload tùy tiện vào:
project root
Nên sử dụng Laravel filesystem:
$image->store('posts', 'public');
Sau đó:
php artisan storage:link
Laravel filesystem cung cấp abstraction cho local, public và các storage backend khác.
Production nên sử dụng:
HTTPS
Không nên:
HTTP
để đăng nhập.
Mô hình:
Browser
│
HTTPS
▼
Nginx
▼
Laravel
HTTPS bảo vệ dữ liệu trên đường truyền.
Có thể cấu hình một số security headers tại Nginx.
Ví dụ:
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
Laravel 13 deployment configuration mẫu cũng sử dụng các header này.
Không nên copy một danh sách security headers một cách máy móc.
Mỗi header cần hiểu rõ tác dụng và khả năng ảnh hưởng tới application.
Các endpoint nhạy cảm như:
Login
Register
Password Reset
API
nên có rate limiting phù hợp.
Ví dụ:
User
↓
Login attempts
↓
Rate Limit
↓
Too many requests
↓
Block / Throttle
Điều này giúp giảm abuse và brute-force attempts.
Production không nên bật debug để “xem lỗi”.
Hãy sử dụng logs.
Ví dụ:
Log::error('Payment failed', [
'order_id' => $order->id,
]);
Log thường nằm trong:
storage/logs/
Có thể kiểm tra:
tail -f storage/logs/laravel.log
hoặc:
tail -f storage/logs/*.log
Laravel 13 có health route:
/up
Mặc định route này trả:
200
nếu application boot thành công.
Nếu application gặp lỗi boot:
500
Laravel cung cấp route này để uptime monitor, load balancer hoặc hệ thống orchestration sử dụng.
Ví dụ:
https://example.com/up
Có thể dùng:
Monitoring
│
▼
https://example.com/up
│
├── 200 → OK
│
└── 500 → Problem
Đây là một cách đơn giản để kiểm tra application còn hoạt động hay không.
Từ Bài 45 đến Bài 49, quy trình deployment của chúng ta có thể là:
Developer
│
▼
Git
│
▼
VPS
│
▼
composer install --no-dev --optimize-autoloader
│
▼
npm run build
│
▼
php artisan migrate --force
│
▼
php artisan optimize
│
▼
php artisan reload
│
▼
Nginx
│
▼
Laravel 13
Laravel 13 deployment documentation cũng khuyến nghị tối ưu application và reload các long-running services sau deployment để chúng sử dụng code mới.
Có thể hình dung quy trình:
git pull origin main
composer install --no-dev --optimize-autoloader
npm install
npm run build
php artisan migrate --force
php artisan storage:link
php artisan optimize
php artisan reload
Tùy kiến trúc Queue/Worker, có thể cần đảm bảo process monitor như Supervisor khởi động lại worker sau khi reload.
Development:
php artisan migrate
Production:
php artisan migrate --force
Flag:
--force
cho phép migration chạy trong production mà không yêu cầu interactive confirmation.
Nhưng:
Trước khi migration production, phải hiểu migration sẽ thay đổi database như thế nào.
Đặc biệt với database lớn:
ALTER TABLE
DROP COLUMN
Index
Data migration
có thể ảnh hưởng production.
Một production application phải nghĩ đến:
Nếu database hỏng thì sao?
Không nên chỉ có:
Database
mà cần:
Database
│
▼
Backup
│
├── Daily
├── Weekly
└── Retention
Ví dụ MySQL có thể backup bằng:
mysqldump
Điều quan trọng không chỉ là:
Backup
mà còn:
Restore được hay không?
Một backup chưa từng thử restore chưa thể xem là chiến lược backup hoàn chỉnh.
Database không phải dữ liệu duy nhất.
Blog CMS còn có:
Post Images
User Avatars
Uploaded Files
Nếu các file này nằm trong:
storage/app/public
thì cũng cần có chiến lược backup.
Production backup:
Database
+
Uploaded Files
+
Environment/Secrets strategy
vendor và node_modules như dữ liệu chínhThông thường source repository có:
composer.json
composer.lock
package.json
package-lock.json
Có thể cài lại:
composer install
npm install
Do đó production backup nên tập trung vào:
Database
Uploaded Files
Application Source
Configuration/Secrets
và chiến lược khôi phục phù hợp.
Một Laravel production cơ bản:
[✓] PHP production configuration
[✓] OPcache
[✓] Composer optimized autoload
[✓] Config cache
[✓] Route cache
[✓] Event cache
[✓] View cache
[✓] Database indexes
[✓] Pagination
[✓] Eager loading
[✓] Appropriate cache
[✓] Queue
[✓] Scheduler
[✓] Nginx
[✓] PHP-FPM
Không phải tất cả đều phải tối ưu cực đại ngay từ đầu.
Nguyên tắc:
Đo → tìm bottleneck → tối ưu.
Ví dụ application chậm.
Không nên ngay lập tức:
Redis
Horizon
Octane
Multiple servers
Load Balancer
Microservices
trong khi vấn đề thực sự là:
N+1 Query
hoặc:
SELECT *
hoặc:
Không có index
Quy trình tốt hơn:
Problem
↓
Measure
↓
Identify bottleneck
↓
Optimize
↓
Measure again
Sau 49 bài, Blog CMS đã đi từ:
PHP
đến:
Laravel 13
và cuối cùng:
INTERNET
│
▼
HTTPS
│
▼
Nginx
│
┌───────────┴───────────┐
│ │
PHP-FPM Static Files
│
▼
Laravel 13
│
┌─────────┼─────────┐
│ │ │
▼ ▼ ▼
MySQL Cache Storage
│
│
▼
Database
Queue:
Laravel → Queue → Worker → Supervisor
Scheduler:
Cron → schedule:run → Laravel Scheduler
Đây không còn là một project PHP đơn giản.
Đây đã là một Laravel application có kiến trúc production cơ bản.
.env mẫuVí dụ:
APP_NAME="Blog CMS"
APP_ENV=production
APP_KEY=base64:YOUR_REAL_APP_KEY
APP_DEBUG=false
APP_URL=https://example.com
LOG_CHANNEL=stack
LOG_LEVEL=error
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=blog
DB_USERNAME=blog_user
DB_PASSWORD=YOUR_DATABASE_PASSWORD
QUEUE_CONNECTION=database
CACHE_STORE=database
Không copy nguyên mẫu này vào production.
Các giá trị:
APP_KEY
DB_PASSWORD
MAIL_PASSWORD
API_KEY
phải sử dụng secret thật của server.
Sau khi deploy code:
composer install --no-dev --optimize-autoloader
Build frontend:
npm run build
Migration:
php artisan migrate --force
Storage:
php artisan storage:link
Optimization:
php artisan optimize
Reload:
php artisan reload
Nếu sử dụng Supervisor cho queue worker, kiểm tra:
sudo supervisorctl status
Kiểm tra Nginx:
sudo nginx -t
Kiểm tra application:
https://example.com/up
optimize:clear?Nếu production có vấn đề liên quan đến cache configuration:
php artisan optimize:clear
Sau đó kiểm tra lại .env và configuration.
Sau khi sửa xong, có thể chạy:
php artisan optimize
Tư duy:
Clear
↓
Fix
↓
Optimize
Không nên chạy optimize:clear liên tục trên production mà không hiểu lý do.
APP_DEBUG=true
Production.
→ Có nguy cơ lộ thông tin.
Nginx:
root /var/www/blog;
→ Sai.
Đúng:
root /var/www/blog/public;
Không chạy:
php artisan optimize
→ Có thể bỏ qua các optimization cache production.
Deploy code mới nhưng Worker vẫn chạy code cũ.
→ Reload/restart long-running services.
Không có backup database.
→ Khi database hỏng, mất dữ liệu.
Upload file nhưng không:
php artisan storage:link
→ Image không truy cập được.
Dùng:
chmod -R 777
→ Cấp quyền quá rộng.
Query:
Model::all();
trên bảng cực lớn.
→ Có thể gây tốn RAM và chậm.
[ ] APP_DEBUG=false
[ ] APP_ENV=production
[ ] HTTPS
[ ] .env không commit Git
[ ] APP_KEY được bảo vệ
[ ] Database password được bảo vệ
[ ] Không expose project root
[ ] Nginx root → public/
[ ] Validation server-side
[ ] Authorization
[ ] CSRF
[ ] Password hashing
[ ] File upload validation
[ ] Rate limiting
[ ] Security headers phù hợp
[ ] Logs
[ ] Database backup
[ ] Uploaded files backup
[ ] Queue Worker monitoring
[ ] Composer optimized autoload
[ ] Config cache
[ ] Event cache
[ ] Route cache
[ ] View cache
[ ] Database indexes
[ ] Pagination
[ ] Eager loading
[ ] Query optimization
[ ] Cache dữ liệu phù hợp
[ ] Vite production build
[ ] Queue cho tác vụ nặng
[ ] Scheduler cho tác vụ định kỳ
Đổi:
APP_ENV=production
APP_DEBUG=false
Sau đó kiểm tra:
App::environment();
Chạy:
php artisan optimize
Sau đó kiểm tra website.
Chạy:
php artisan optimize:clear
Sau đó:
php artisan optimize
Quan sát sự khác nhau.
Chạy:
php artisan route:cache
Sau đó:
php artisan route:list
Chạy:
php artisan view:cache
Kiểm tra Blog CMS.
Viết:
Cache::remember(...)
để cache danh sách category.
Sau đó khi category thay đổi:
Cache::forget(...)
Mở:
/up
và kiểm tra HTTP status.
Kiểm tra production:
APP_DEBUG=false
và thử truy cập:
/.env
Kết quả phải là:
Không được phép truy cập
Sau bài này, chúng ta đã hoàn thành phần quan trọng nhất của deployment Laravel 13.
Các nguyên tắc cần nhớ:
ENV
↓
Configuration
↓
Cache
↓
Optimization
↓
Security
↓
Monitoring
Production không đơn giản chỉ là:
Upload source code
Mà là:
Application
+
Web Server
+
PHP-FPM
+
Database
+
Cache
+
Queue
+
Scheduler
+
Security
+
Monitoring
+
Backup
php artisan optimize
php artisan optimize:clear
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
php artisan storage:link
php artisan migrate --force
php artisan reload
Laravel 13 documentation xác nhận reload được dùng để terminate các long-running services như queue workers, Reverb hoặc Octane sau deployment để process monitor có thể khởi động lại chúng với code mới.
Một developer Laravel không chỉ cần biết:
Controller
Model
Migration
Blade
Route
mà khi đưa application ra Internet còn phải hiểu:
Code
↓
Security
↓
Performance
↓
Deployment
↓
Monitoring
↓
Backup
Đó chính là sự khác biệt giữa:
"Làm được một Laravel project"
và:
"Biết triển khai một Laravel application"
Sau:
Bài 45 — Deploy Shared Hosting
Bài 46 — Deploy VPS
Bài 47 — Nginx
Bài 48 — Queue Worker & Scheduler
Bài 49 — Production Tips
chúng ta đã hoàn thành toàn bộ phần:
TRIỂN KHAI LARAVEL 13
Kiến trúc cuối cùng:
INTERNET
│
HTTPS
│
▼
NGINX
│
▼
PHP-FPM
│
▼
┌─────────────┐
│ Laravel 13 │
└──────┬──────┘
│
┌──────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
MySQL Cache Storage
│
│
▼
Database
Laravel
│
▼
Queue
│
▼
Worker
│
▼
Supervisor
Cron
│
▼
Laravel Scheduler
Và chỉ còn một bài cuối cùng:
Chúng ta sẽ tổng hợp toàn bộ những gì đã học:
PHP
↓
Laravel 13
↓
MVC
↓
Database
↓
Eloquent
↓
Relationship
↓
Authentication
↓
Authorization
↓
CRUD
↓
Upload
↓
Search
↓
Pagination
↓
Soft Delete
↓
Events
↓
Queue
↓
Mail
↓
Notification
↓
Cache
↓
Storage
↓
Logging
↓
REST API
↓
Sanctum
↓
Nginx
↓
VPS
↓
Production
và ráp lại thành Blog CMS hoàn chỉnh.
x1
Cập nhật: 2026-09-27T15:53:12.479+07:00
Ở Bài 47, chúng ta đã cấu hình:
Internet
↓
Nginx
↓
PHP-FPM
↓
Laravel 13
Nhưng một ứng dụng thực tế thường không chỉ xử lý request ngay lập tức.
Ví dụ Blog CMS của chúng ta có thể cần:
gửi email;
gửi notification;
xử lý ảnh;
tạo báo cáo;
xóa dữ liệu cũ;
đồng bộ dữ liệu;
gửi email hàng loạt;
chạy tác vụ mỗi ngày;
dọn database;
kiểm tra bài viết scheduled.
Nếu bắt người dùng chờ tất cả những việc này hoàn thành trong HTTP request thì website sẽ chậm.
Laravel cung cấp hai cơ chế rất quan trọng:
Queue
Scheduler
Trong bài này chúng ta sẽ đưa cả hai lên VPS production.
Queue có thể hiểu đơn giản là:
Đưa một công việc vào hàng đợi để xử lý sau.
Ví dụ người dùng đăng ký tài khoản:
User Register
│
▼
Laravel
│
├── Lưu User
│
└── Đưa Email vào Queue
│
▼
Worker
│
▼
Gửi Email
Thay vì:
User
│
▼
Register
│
▼
Gửi Email
│
▼
Chờ SMTP
│
▼
Response
chúng ta có:
User
│
▼
Register
│
▼
Queue Job
│
▼
Response nhanh
Sau đó Worker xử lý email ở background.
Giả sử Blog CMS có chức năng gửi email cho 10.000 người dùng.
Nếu xử lý trực tiếp:
foreach ($users as $user) {
Mail::to($user)->send(...);
}
request có thể phải chờ rất lâu.
Không nên bắt browser chờ:
Browser
│
│ Request
▼
Laravel
│
├── Email 1
├── Email 2
├── Email 3
├── ...
└── Email 10000
│
▼
Response
Thay vào đó:
Browser
│
▼
Laravel
│
▼
Queue
│
└── 10000 jobs
│
▼
Worker
Browser nhận response sớm hơn.
Một Job đại diện cho một công việc.
Ví dụ:
SendWelcomeEmail
hoặc:
ProcessPostImage
hoặc:
GenerateReport
Tạo Job:
php artisan make:job SendWelcomeEmail
Laravel tạo:
app/Jobs/SendWelcomeEmail.php
Ví dụ:
<?php
namespace App\Jobs;
use App\Models\User;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class SendWelcomeEmail implements ShouldQueue
{
use Queueable;
public function __construct(
public User $user
) {
}
public function handle(): void
{
// Xử lý công việc
}
}
Điểm quan trọng:
implements ShouldQueue
cho Laravel biết Job này cần được xử lý thông qua Queue.
Sau khi tạo Job:
SendWelcomeEmail::dispatch($user);
Ví dụ:
use App\Jobs\SendWelcomeEmail;
SendWelcomeEmail::dispatch($user);
Lúc này ứng dụng không nhất thiết phải thực hiện toàn bộ công việc ngay trong request.
Job được đưa vào Queue để Worker xử lý.
Laravel hỗ trợ nhiều queue backend.
Một số lựa chọn phổ biến:
Database
Redis
Amazon SQS
Trong khóa học, chúng ta bắt đầu bằng:
Database Queue
vì dễ hiểu và phù hợp để học.
Database Queue lưu Job vào database.
Mô hình:
Laravel
│
▼
jobs table
│
▼
Queue Worker
│
▼
handle()
Laravel có thể tạo migration cho jobs table bằng:
php artisan make:queue-table
Sau đó:
php artisan migrate
Database sẽ có bảng:
jobs
Ngoài ra có thể có:
failed_jobs
để lưu những Job thất bại.
Trong .env:
QUEUE_CONNECTION=database
Sau khi thay đổi .env, trong production nên đảm bảo configuration cache được cập nhật.
Ví dụ:
php artisan config:clear
hoặc sau khi deploy:
php artisan optimize
Đây là phần cực kỳ quan trọng.
Queue chỉ chứa Job.
Ai xử lý Job?
Queue Worker
Chạy:
php artisan queue:work
Worker sẽ:
Queue
│
├── Job 1
├── Job 2
├── Job 3
└── Job 4
│
▼
Worker
│
▼
handle()
queue:work chạy liên tụcKhi chạy:
php artisan queue:work
Laravel Worker không chỉ xử lý một Job rồi kết thúc.
Nó tiếp tục chờ:
Job
↓
Process
↓
Job
↓
Process
↓
Job
↓
Process
↓
...
Đây là một long-running process.
Vì vậy production cần một process manager để đảm bảo Worker luôn được chạy.
Ví dụ:
php artisan queue:work
Bạn SSH vào VPS và chạy.
Sau đó:
SSH disconnect
Worker có thể kết thúc.
Đây không phải cách triển khai production tốt.
Chúng ta cần:
Supervisor
hoặc một process manager tương đương.
Supervisor là process manager.
Nó có nhiệm vụ:
Kiểm tra Worker
│
├── Worker đang chạy
│ ↓
│ OK
│
└── Worker chết
↓
Restart
Như vậy:
Laravel Queue
↓
queue:work
↓
Supervisor
↓
Luôn duy trì Worker
Trên VPS:
sudo apt update
Sau đó:
sudo apt install supervisor
Kiểm tra:
sudo systemctl status supervisor
Giả sử project:
/var/www/blog
Tạo:
sudo nano /etc/supervisor/conf.d/blog-worker.conf
Nội dung:
[program:blog-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/blog/artisan queue:work database --sleep=3 --tries=3 --timeout=90
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/blog/storage/logs/worker.log
stopwaitsecs=3600
Cấu hình này tạo:
Worker 1
Worker 2
numprocsnumprocs=2
nghĩa là chạy hai Worker.
Supervisor
│
├── Worker 1
│
└── Worker 2
Nếu có nhiều Job:
Job 1 ── Worker 1
Job 2 ── Worker 2
Job 3 ── Worker 1
Job 4 ── Worker 2
Nhờ đó có thể xử lý nhiều công việc đồng thời.
Không nên tăng numprocs tùy tiện.
VPS ít CPU/RAM mà chạy quá nhiều Worker có thể làm server quá tải.
--sleep--sleep=3
Worker sẽ chờ 3 giây trước khi kiểm tra Queue lại khi không có Job.
Ví dụ:
Không có Job
↓
Chờ 3 giây
↓
Kiểm tra
↓
Không có
↓
Chờ 3 giây
--tries--tries=3
Nếu Job thất bại:
Attempt 1
↓
Fail
↓
Attempt 2
↓
Fail
↓
Attempt 3
↓
Fail
↓
Failed Job
Không phải mọi Job đều nên retry vô hạn.
--timeout--timeout=90
Worker cho phép Job chạy tối đa khoảng thời gian cấu hình trước khi bị timeout.
Con số thực tế cần được chọn dựa trên loại Job.
Ví dụ:
Gửi email
có thể cần thời gian khác:
Xử lý video
hoặc:
Generate report
Không nên đặt timeout quá thấp đối với Job cần nhiều thời gian.
Sau khi tạo configuration:
sudo supervisorctl reread
Sau đó:
sudo supervisorctl update
Kiểm tra:
sudo supervisorctl status
Ví dụ:
blog-worker:blog-worker_00 RUNNING
blog-worker:blog-worker_01 RUNNING
Khi deploy code mới, có một vấn đề quan trọng.
Queue Worker là process chạy lâu dài.
Nó có thể tiếp tục sử dụng code cũ đã được load vào memory.
Laravel cung cấp:
php artisan queue:restart
để yêu cầu Worker restart một cách graceful sau khi hoàn thành Job hiện tại. Laravel documentation cũng lưu ý rằng queue workers là long-lived processes và cần được restart/reload sau deployment.
Với Laravel 13, có thêm command tổng quát:
php artisan reload
để terminate các reloadable long-running services sau deployment; process monitor như Supervisor có thể khởi động chúng lại.
Trong mô hình VPS + Supervisor của khóa học, có thể dùng:
php artisan queue:restart
sau deployment.
Một Job có thể thất bại.
Ví dụ:
SMTP Server
↓
Connection failed
hoặc:
API
↓
Timeout
Laravel có thể lưu Job thất bại trong:
failed_jobs
Xem:
php artisan queue:failed
Có thể retry:
php artisan queue:retry all
hoặc một Job cụ thể:
php artisan queue:retry 5
Trong đó:
5
là ID của failed Job.
Có thể xóa một Job:
php artisan queue:forget 5
Hoặc xóa toàn bộ failed jobs:
php artisan queue:flush
Cẩn thận với:
queue:flush
vì nó xóa danh sách failed jobs.
Blog CMS có thể có:
Post Published
│
▼
SendPostPublishedNotification
│
▼
Queue
│
▼
Worker
│
├── Email
└── Notification
Controller không cần xử lý tất cả công việc nặng trong request.
Laravel Mail có thể được queue.
Ví dụ Mailable:
<?php
namespace App\Mail;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
class WelcomeMail extends Mailable implements ShouldQueue
{
public function build()
{
return $this->subject('Welcome')
->view('emails.welcome');
}
}
Khi Mailable implement:
ShouldQueue
email sẽ được đưa vào Queue thay vì bắt request phải chờ việc gửi email hoàn thành. Laravel 13 documentation hỗ trợ queued mailables thông qua ShouldQueue.
Ở Bài 34 — Event & Listener, chúng ta đã học Event và Listener.
Listener cũng có thể chạy Queue.
Ví dụ:
use Illuminate\Contracts\Queue\ShouldQueue;
class SendPostNotification implements ShouldQueue
{
public function handle(PostPublished $event): void
{
// Send notification
}
}
Mô hình:
Event
│
▼
Listener
│
├── ShouldQueue
│
▼
Queue
│
▼
Worker
Laravel 13 hỗ trợ queued listeners và các cơ chế retry/backoff cho listener.
Laravel 13 có thêm khả năng queue routing theo Job class.
Ví dụ:
use Illuminate\Support\Facades\Queue;
Queue::route(
ProcessPodcast::class,
connection: 'redis',
queue: 'podcasts'
);
Điều này cho phép định tuyến mặc định cho một Job cụ thể thay vì phải cấu hình riêng ở từng nơi. Đây là một trong những thay đổi mới của Laravel 13.
Đối với khóa học cơ bản, chúng ta chưa cần sử dụng tính năng này ngay.
Điều quan trọng trước tiên là hiểu:
Job
↓
Queue
↓
Worker
Queue xử lý:
Công việc đang chờ
Scheduler xử lý:
Công việc cần chạy theo thời gian
Ví dụ:
Mỗi phút
Mỗi 5 phút
Mỗi giờ
Mỗi ngày
Mỗi tuần
Ví dụ Blog CMS:
00:00 mỗi ngày
↓
Dọn dữ liệu cũ
hoặc:
Mỗi 5 phút
↓
Kiểm tra bài viết scheduled
Có Job
↓
Xử lý Job
Đến thời gian
↓
Chạy Task
Có thể kết hợp:
Scheduler
│
▼
Dispatch Job
│
▼
Queue
│
▼
Worker
Đây là mô hình rất mạnh.
Trong Laravel hiện đại, scheduler có thể được định nghĩa trong:
routes/console.php
Ví dụ:
use Illuminate\Support\Facades\Schedule;
Schedule::call(function () {
// Task
})->daily();
Laravel scheduler cho phép định nghĩa lịch chạy ngay trong source code thay vì phải tạo từng cron entry riêng cho từng task.
Giả sử chúng ta có command:
php artisan make:command CleanOldPosts
Sau đó có thể schedule:
use Illuminate\Support\Facades\Schedule;
Schedule::command('posts:clean')
->daily();
Mỗi ngày Laravel sẽ chạy:
posts:clean
Có thể schedule Job:
use App\Jobs\GenerateDailyReport;
use Illuminate\Support\Facades\Schedule;
Schedule::job(new GenerateDailyReport)
->daily();
Mô hình:
Scheduler
│
│ mỗi ngày
▼
GenerateDailyReport
│
▼
Queue
│
▼
Worker
Đây là cách kết hợp Scheduler và Queue rất phổ biến.
Ví dụ:
Schedule::command('posts:clean')
->daily();
Mỗi giờ:
Schedule::command('posts:sync')
->hourly();
Mỗi 5 phút:
Schedule::command('posts:sync')
->everyFiveMinutes();
Mỗi 10 phút:
Schedule::command('posts:sync')
->everyTenMinutes();
Mỗi ngày lúc 02:00:
Schedule::command('posts:clean')
->dailyAt('02:00');
Đây là điểm rất quan trọng.
Laravel Scheduler không tự nhiên chạy chỉ vì chúng ta viết:
Schedule::command(...);
Trên production, server cần kích hoạt scheduler.
Một cách phổ biến là Cron chạy:
php artisan schedule:run
mỗi phút.
Cron:
Every minute
│
▼
schedule:run
│
▼
Laravel kiểm tra Schedule
│
├── Task đến giờ?
│ │
│ └── Có → chạy
│
└── Chưa đến giờ → bỏ qua
Laravel documentation mô tả mô hình này: chỉ cần một cron entry chạy schedule:run mỗi phút; Laravel tự kiểm tra các task đã được định nghĩa.
Trên VPS:
crontab -e
Thêm:
* * * * * cd /var/www/blog && php artisan schedule:run >> /dev/null 2>&1
Ý nghĩa:
* * * * *
= mỗi phút.
Sau đó:
cd /var/www/blog
đi vào Laravel project.
Rồi:
php artisan schedule:run
Giả sử có:
Schedule::command('posts:clean')
->dailyAt('02:00');
Cron vẫn chạy:
01:58 → schedule:run
01:59 → schedule:run
02:00 → schedule:run
02:01 → schedule:run
Nhưng chỉ tại:
02:00
Laravel thực hiện:
posts:clean
Các phút còn lại:
Không có task cần chạy
Trong môi trường development có thể chạy:
php artisan schedule:work
Scheduler sẽ chạy foreground và liên tục kiểm tra schedule. Đây là cách thuận tiện để test scheduler local thay vì cấu hình Cron trên máy development.
Blog CMS của chúng ta có thể có:
Scheduler
│
├── Every minute
│ └── Publish scheduled posts
│
├── Every hour
│ └── Update statistics
│
├── Daily
│ └── Cleanup old data
│
└── Weekly
└── Generate report
Ví dụ:
use Illuminate\Support\Facades\Schedule;
Schedule::command('posts:publish-scheduled')
->everyMinute();
Schedule::command('stats:update')
->hourly();
Schedule::command('cleanup:old-data')
->dailyAt('02:00');
Một task có thể mất nhiều thời gian.
Ví dụ:
Task bắt đầu
│
├── chạy 10 phút
│
└── Task tiếp theo lại được gọi
Có thể dẫn tới:
Task #1 đang chạy
Task #2 chạy cùng lúc
Nếu không muốn điều này, có thể dùng:
Schedule::command('reports:generate')
->daily()
->withoutOverlapping();
Khi đó Laravel cố gắng tránh việc task mới bắt đầu trong khi instance trước vẫn đang chạy.
Nếu hệ thống có nhiều server:
Server 1
Server 2
Server 3
Scheduler có thể cần tránh việc cùng một task chạy trên cả ba server.
Trong các trường hợp phù hợp, Laravel cung cấp cơ chế:
->onOneServer()
Ví dụ:
Schedule::command('reports:generate')
->daily()
->onOneServer();
Cơ chế này cần cache backend phù hợp để các server phối hợp với nhau.
Đây là kiến trúc rất đáng nhớ:
Scheduler
│
│ mỗi ngày
▼
GenerateReport
│
▼
Queue
│
▼
Worker
│
▼
Report generated
Scheduler quyết định:
Khi nào?
Queue quyết định:
Xử lý công việc ở background như thế nào?
Một ví dụ thực tế:
BLOG CMS
│
┌────────────┴────────────┐
│ │
Queue Scheduler
│ │
▼ ▼
Send Mail Scheduled Posts
Notification Daily Cleanup
Process Image Daily Report
│ │
▼ ▼
Worker Artisan/Job
Đây chính là kiến trúc production mà chúng ta đang xây dựng.
Khi deploy version mới:
git pull
Sau đó:
composer install --no-dev --optimize-autoloader
Nếu có frontend:
npm install
npm run build
Migration:
php artisan migrate --force
Cache:
php artisan optimize
Sau đó cần xử lý Worker.
Có thể:
php artisan queue:restart
hoặc sử dụng:
php artisan reload
để reload các long-running services mà Laravel quản lý, tùy kiến trúc deployment.
Với VPS sử dụng Supervisor:
Deploy
↓
queue:restart
↓
Worker graceful exit
↓
Supervisor phát hiện process kết thúc
↓
Worker mới
↓
Code mới
Giả sử:
10:00
Deploy Version 1
Worker load:
Version 1
Sau đó:
10:30
Deploy Version 2
Nếu Worker vẫn đang chạy:
Worker
↓
Version 1
trong khi website:
Nginx
↓
Laravel Version 2
Có thể xảy ra:
Website = Version 2
Worker = Version 1
Vì vậy deployment cần reload/restart long-running processes.
Kiến trúc hoàn chỉnh:
VPS
│
┌─────────┴─────────┐
│ │
Nginx Supervisor
│ │
▼ ▼
PHP-FPM Queue Worker
│ │
▼ ▼
Laravel 13 Queue Backend
│
▼
MySQL
Scheduler:
Cron
│
▼
schedule:run
│
▼
Laravel Scheduler
Trên VPS:
sudo supervisorctl status
Ví dụ:
blog-worker:blog-worker_00 RUNNING
blog-worker:blog-worker_01 RUNNING
Nếu:
STOPPED
kiểm tra:
sudo supervisorctl tail blog-worker:blog-worker_00
hoặc xem log:
tail -f /var/www/blog/storage/logs/worker.log
Laravel:
php artisan queue:failed
Nếu không có failed jobs:
No failed jobs found.
Nếu có:
ID
Connection
Queue
Class
Failed At
hãy kiểm tra nguyên nhân.
Có thể xem các scheduled tasks bằng:
php artisan schedule:list
Kết quả có thể cho biết:
Command
Frequency
Next Due
Ví dụ:
posts:publish-scheduled Every Minute
stats:update Every Hour
reports:generate Daily
Đây là cách rất hữu ích để kiểm tra xem Scheduler đã nhận configuration chưa.
Khi hệ thống lớn, chúng ta cần biết:
Có bao nhiêu Job đang chờ?
Worker có xử lý kịp không?
Queue có bị nghẽn không?
Laravel có:
queue:monitor
để theo dõi queue vượt ngưỡng.
Ví dụ:
php artisan queue:monitor database:default --max=100
Khi queue vượt ngưỡng, Laravel có thể phát sự kiện QueueBusy để ứng dụng phản ứng phù hợp.
Database Queue phù hợp để học và các ứng dụng nhỏ.
Khi hệ thống lớn, Redis thường được sử dụng làm queue backend:
Laravel
│
▼
Redis
│
▼
Worker
.env có thể cấu hình:
QUEUE_CONNECTION=redis
Kiến trúc:
Laravel
│
▼
Redis
│
├── Job 1
├── Job 2
├── Job 3
└── Job 4
│
▼
Worker
Trong khóa học hiện tại, chúng ta chưa cần triển khai Redis ngay.
Database Queue giúp người mới hiểu bản chất Queue trước.
Nếu sử dụng Redis Queue, Laravel có:
Laravel Horizon
Horizon cung cấp giao diện để theo dõi Queue.
Có thể quan sát:
Jobs
Throughput
Runtime
Failed Jobs
Workers
Horizon đặc biệt hữu ích khi ứng dụng có hệ thống Queue lớn.
Trong Blog CMS cơ bản, Horizon có thể xem là phần nâng cao.
Hai thứ này rất dễ nhầm.
Xử lý:
HTTP Request
Ví dụ:
GET /blog
POST /login
GET /admin/posts
Xử lý:
Background Job
Ví dụ:
Send Email
Generate Report
Process Image
Notification
Mô hình:
HTTP
│
▼
Nginx
│
▼
PHP-FPM
│
▼
Laravel
và:
Queue
│
▼
Worker
│
▼
Job
Là cơ chế của hệ điều hành.
Ví dụ:
* * * * * php artisan schedule:run
Là cơ chế của Laravel.
Ví dụ:
Schedule::command('posts:clean')
->daily();
Kết hợp:
Linux Cron
│
▼
schedule:run
│
▼
Laravel Scheduler
│
▼
Task
Laravel giúp chúng ta quản lý lịch chạy trong source code thay vì phải tạo hàng loạt Cron entry.
.env:
QUEUE_CONNECTION=database
Tạo bảng:
php artisan make:queue-table
php artisan migrate
Chạy Worker:
php artisan queue:work
Production:
Supervisor
↓
queue:work
routes/console.php:
<?php
use Illuminate\Support\Facades\Schedule;
Schedule::command('posts:publish-scheduled')
->everyMinute();
Schedule::command('cleanup:old-data')
->dailyAt('02:00');
Cron:
* * * * * cd /var/www/blog && php artisan schedule:run >> /dev/null 2>&1
Sau Bài 48, Blog CMS của chúng ta đã tiến thêm một bước:
INTERNET
│
▼
┌───────┐
│ Nginx │
└───┬───┘
│
▼
PHP-FPM
│
▼
┌─────────────┐
│ Laravel 13 │
└──────┬──────┘
│
┌─────────────┼─────────────┐
│ │ │
▼ ▼ ▼
MySQL Storage Cache
│
│
┌───────┴───────┐
│ │
▼ ▼
Queue Scheduler
│ │
▼ ▼
Worker Cron
│
▼
Background
Jobs
Đây là một architecture production cơ bản nhưng rất thực tế.
Trước khi đưa Queue lên production:
[ ] QUEUE_CONNECTION đã cấu hình
[ ] jobs table tồn tại
[ ] failed_jobs table tồn tại
[ ] Job implements ShouldQueue
[ ] queue:work chạy được
[ ] Supervisor đã cài
[ ] Supervisor configuration đúng
[ ] Worker đang RUNNING
[ ] Worker log hoạt động
[ ] queue:failed kiểm tra được
Scheduler:
[ ] Schedule đã định nghĩa
[ ] schedule:list kiểm tra được
[ ] Cron đã cấu hình
[ ] Cron chạy mỗi phút
[ ] Task không bị overlapping ngoài ý muốn
[ ] Scheduler log/error được kiểm tra
Deployment:
[ ] Code mới đã deploy
[ ] composer install
[ ] migrate --force
[ ] optimize
[ ] queue:restart / reload
[ ] Supervisor tự khởi động Worker mới
php artisan make:job TestQueueJob
Cho Job ghi log:
Log::info('Queue Job executed');
Dispatch:
TestQueueJob::dispatch();
Sau đó chạy:
php artisan queue:work
Kiểm tra:
storage/logs/laravel.log
Tạo Job cố tình phát sinh exception:
throw new Exception('Test Queue Error');
Sau đó:
php artisan queue:work --tries=1
Kiểm tra:
php artisan queue:failed
Tạo Schedule:
Schedule::call(function () {
Log::info('Scheduler executed');
})->everyMinute();
Chạy:
php artisan schedule:work
Sau đó kiểm tra:
storage/logs/laravel.log
Cài:
sudo apt install supervisor
Tạo:
/etc/supervisor/conf.d/blog-worker.conf
Khởi động Worker thông qua Supervisor.
Kiểm tra:
sudo supervisorctl status
Thêm:
* * * * * cd /var/www/blog && php artisan schedule:run >> /dev/null 2>&1
Sau đó kiểm tra:
php artisan schedule:list
Sau bài này chúng ta đã hiểu hai khái niệm quan trọng:
QUEUE
và:
SCHEDULER
Queue dùng để xử lý:
Background Jobs
Ví dụ:
Email
Notification
Image Processing
Report
API Sync
Scheduler dùng để xử lý:
Timed Tasks
Ví dụ:
Every Minute
Every Hour
Daily
Weekly
Kiến trúc:
Laravel
│
┌───────┴────────┐
│ │
Queue Scheduler
│ │
▼ ▼
Worker Cron
│ │
▼ ▼
Job Task
Và trong production:
Supervisor
│
▼
Queue Worker
còn:
Cron
│
▼
schedule:run
│
▼
Laravel Scheduler
php artisan queue:work
php artisan queue:restart
php artisan queue:failed
php artisan queue:retry all
php artisan schedule:run
php artisan schedule:work
php artisan schedule:list
Supervisor
↓
Queue Worker
và:
Cron
↓
schedule:run
Quan trọng nhất:
Queue = XỬ LÝ VIỆC NỀN
Scheduler = CHẠY VIỆC THEO LỊCH
Khi kết hợp:
Scheduler
↓
Dispatch Job
↓
Queue
↓
Worker
↓
Background Processing
thì Laravel 13 có thể xử lý được rất nhiều công việc production mà không bắt người dùng phải chờ trong HTTP request.
Bài tiếp theo: Bài 49 — Production Tips
Chúng ta sẽ hoàn thiện phần triển khai Laravel 13 với:
Cache
Optimize
ENV
APP_DEBUG
Security
Performance
Production Configuration
để Blog CMS sẵn sàng bước sang Bài 50 — Tổng kết dự án Blog CMS.
x1
Cập nhật: 2026-09-25T19:54:46.833+07:00
Ở Bài 46, chúng ta đã đưa Laravel 13 lên VPS.
Nhưng một Laravel project trên VPS chưa thể tự nhiên hoạt động như một website production chỉ bằng việc upload source code.
Chúng ta cần một Web Server.
Một mô hình phổ biến là:
Internet
│
▼
Nginx
│
▼
PHP-FPM
│
▼
Laravel 13
│
├── MySQL
├── Storage
└── Cache
Trong bài này chúng ta sẽ học cách cấu hình Nginx cho Laravel 13.
Nginx là một web server và reverse proxy rất phổ biến.
Nó có thể:
nhận HTTP/HTTPS request;
phục vụ file tĩnh;
xử lý domain;
chuyển request PHP cho PHP-FPM;
reverse proxy;
load balancing;
cache;
giới hạn request;
cấu hình HTTPS;
chạy nhiều website trên cùng một VPS.
Trong Laravel deployment, Nginx thường đứng phía trước PHP-FPM.
Ví dụ:
Browser
│
│ HTTPS
▼
Nginx
│
│ FastCGI
▼
PHP-FPM
│
▼
Laravel
Laravel không phải là web server production.
Khi development, chúng ta thường chạy:
php artisan serve
Sau đó truy cập:
http://127.0.0.1:8000
Đây là môi trường phát triển.
Production thường sử dụng:
Nginx
+
PHP-FPM
+
Laravel
Nginx nhận request:
GET /
sau đó đưa request đến Laravel.
Đây là phần rất quan trọng.
Nginx không trực tiếp chạy PHP.
PHP được xử lý bởi:
PHP-FPM
PHP-FPM là:
PHP FastCGI Process Manager
Có thể hình dung:
┌──────────────┐
│ Browser │
└──────┬───────┘
│
▼
┌──────────────┐
│ Nginx │
└──────┬───────┘
│
FastCGI │
▼
┌──────────────┐
│ PHP-FPM │
└──────┬───────┘
│
▼
┌──────────────┐
│ Laravel 13 │
└──────────────┘
Nginx đảm nhiệm phần web server.
PHP-FPM đảm nhiệm việc chạy PHP.
Laravel xử lý application logic.
Laravel 13 yêu cầu:
PHP >= 8.3
Vì vậy server của chúng ta trong khóa học sử dụng:
Ubuntu
Nginx
PHP 8.3
PHP-FPM
MySQL
Composer
Laravel 13
Laravel cũng yêu cầu một số PHP extension như:
Ctype
cURL
DOM
Fileinfo
Filter
Hash
Mbstring
OpenSSL
PCRE
PDO
Session
Tokenizer
XML
Đây là các yêu cầu chính thức của Laravel 13.
Trên VPS:
nginx -v
Ví dụ:
nginx version: nginx/1.24.0
Kiểm tra trạng thái:
sudo systemctl status nginx
Nếu đang chạy:
Active: active (running)
Khởi động:
sudo systemctl start nginx
Cho Nginx tự khởi động cùng server:
sudo systemctl enable nginx
Restart:
sudo systemctl restart nginx
Reload:
sudo systemctl reload nginx
Ví dụ PHP 8.3:
sudo systemctl status php8.3-fpm
Nếu chưa chạy:
sudo systemctl start php8.3-fpm
Enable:
sudo systemctl enable php8.3-fpm
Restart:
sudo systemctl restart php8.3-fpm
Trên Ubuntu, các file thường nằm trong:
/etc/nginx/
Cấu trúc phổ biến:
/etc/nginx/
├── nginx.conf
├── sites-available/
│ └── default
└── sites-enabled/
└── default
Trong đó:
sites-available
chứa configuration của website.
sites-enabled
chứa các website đang được enable.
Giả sử project của chúng ta nằm tại:
/var/www/blog
Laravel có:
/var/www/blog/
├── app/
├── bootstrap/
├── config/
├── database/
├── public/
├── resources/
├── routes/
├── storage/
├── vendor/
└── artisan
Điểm quan trọng:
Nginx phải trỏ vào thư mục
public.
Không được trỏ vào:
/var/www/blog
mà phải là:
/var/www/blog/public
Laravel chính thức cũng yêu cầu web server hướng request đến public/index.php, thay vì project root, nhằm tránh expose các file nhạy cảm như .env.
Tạo file:
sudo nano /etc/nginx/sites-available/blog
Nội dung:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
root /var/www/blog/public;
index index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico {
access_log off;
log_not_found off;
}
location = /robots.txt {
access_log off;
log_not_found off;
}
location ~ ^/index\.php(/|$) {
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_buffer_size 32k;
fastcgi_buffers 8 32k;
fastcgi_busy_buffers_size 64k;
fastcgi_hide_header X-Powered-By;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
Đây là cấu hình dựa trên cấu hình Nginx production được Laravel 13 documentation cung cấp, với đường dẫn project và PHP-FPM socket cần thay đổi theo server thực tế.
serverserver {
}
Một server block đại diện cho một website hoặc virtual host.
Ví dụ:
example.com
có thể có một server block.
Website khác:
abc.com
có thể có server block khác.
Do đó một VPS có thể chạy:
VPS
│
├── example.com
│
├── abc.com
│
└── xyz.com
listenlisten 80;
Port:
80
dành cho HTTP.
IPv6:
listen [::]:80;
Khi dùng HTTPS sau này:
listen 443 ssl;
server_nameVí dụ:
server_name example.com www.example.com;
Nginx sẽ biết request dành cho website nào.
Ví dụ browser truy cập:
https://example.com
Nginx tìm server block có:
server_name example.com;
rootĐây là một trong những dòng quan trọng nhất:
root /var/www/blog/public;
Không phải:
root /var/www/blog;
Mà:
root /var/www/blog/public;
Vì Laravel có cấu trúc:
blog/
├── app/
├── config/
├── resources/
├── routes/
├── storage/
├── vendor/
└── public/
├── index.php
├── build/
├── favicon.ico
└── ...
Browser chỉ nên truy cập phần:
public/
Giả sử chúng ta cấu hình:
root /var/www/blog;
Khi đó có nguy cơ expose các file như:
.env
composer.json
composer.lock
artisan
config/
storage/
Đặc biệt .env chứa những thông tin như:
DB_PASSWORD
MAIL_PASSWORD
AWS_SECRET
APP_KEY
Đây là vấn đề bảo mật nghiêm trọng.
Do đó:
❌ /var/www/blog
phải chuyển thành:
✅ /var/www/blog/public
indexindex index.php;
Khi request đến:
/
Nginx biết entry point chính là:
index.php
Laravel sử dụng:
public/index.php
làm entry point của ứng dụng.
location /Phần quan trọng:
location / {
try_files $uri $uri/ /index.php?$query_string;
}
Đây là phần giúp Laravel routing hoạt động.
Ví dụ:
/blog
hoặc:
/blog/laravel-13
không nhất thiết phải là file vật lý.
Nginx sẽ thử:
$uri
Nếu không tồn tại:
$uri/
Nếu vẫn không tồn tại:
/index.php
và giữ query string.
Laravel sau đó nhận request và xử lý:
Route::get('/blog', ...);
hoặc:
Route::get('/blog/{post}', ...);
try_files bằng ví dụGiả sử browser gọi:
/images/logo.png
Nếu file tồn tại:
public/images/logo.png
Nginx phục vụ trực tiếp.
Không cần PHP.
Nhưng nếu gọi:
/blog
không có file:
public/blog
Nginx chuyển request về:
public/index.php
Sau đó Laravel xử lý route.
Ví dụ:
GET /blog
Luồng xử lý:
Browser
│
▼
Nginx
│
├── public/blog tồn tại?
│ │
│ └── Không
│
▼
public/index.php
│
▼
PHP-FPM
│
▼
Laravel Router
│
▼
BlogController
│
▼
Blade
│
▼
HTML
location ~ ^/index\.phpĐây là phần chuyển PHP request cho PHP-FPM:
location ~ ^/index\.php(/|$) {
Laravel 13 documentation sử dụng chính pattern này trong cấu hình Nginx production.
fastcgi_passVí dụ:
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
Nginx nói:
Hãy chuyển PHP request cho PHP-FPM 8.3.
Socket có thể khác tùy server.
Kiểm tra:
ls /var/run/php/
Có thể thấy:
php8.3-fpm.sock
Khi đó:
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
Nếu server sử dụng PHP phiên bản khác thì đường dẫn phải thay đổi tương ứng.
SCRIPT_FILENAMEfastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
Dòng này cho PHP-FPM biết file PHP thực tế cần chạy.
Laravel configuration chính thức cũng sử dụng:
$realpath_root$fastcgi_script_name
trong server block mẫu.
include fastcgi_paramsinclude fastcgi_params;
Nó đưa các FastCGI parameters cần thiết vào request.
Không nên tùy tiện bỏ dòng này khỏi cấu hình.
Cấu hình:
location ~ /\.(?!well-known).* {
deny all;
}
Mục đích là chặn truy cập đến các file hoặc thư mục bắt đầu bằng:
.
Ví dụ:
.env
.git
.gitignore
Trong đó .env đặc biệt quan trọng.
Laravel 13 documentation cũng đưa rule này vào cấu hình Nginx mẫu.
favicon.ico và robots.txtCó thể sử dụng:
location = /favicon.ico {
access_log off;
log_not_found off;
}
location = /robots.txt {
access_log off;
log_not_found off;
}
Hai file này thường được browser hoặc crawler truy cập thường xuyên.
Không cần ghi log những request không đáng kể này.
Sau khi tạo:
/etc/nginx/sites-available/blog
tạo symbolic link:
sudo ln -s /etc/nginx/sites-available/blog /etc/nginx/sites-enabled/blog
Có thể xóa site mặc định:
sudo rm /etc/nginx/sites-enabled/default
Đừng restart Nginx ngay.
Trước tiên:
sudo nginx -t
Nếu đúng:
syntax is ok
test is successful
Sau đó:
sudo systemctl reload nginx
Đây là thói quen rất quan trọng.
Luôn:
Sửa config
↓
nginx -t
↓
OK?
↓
reload nginx
Không nên:
Sửa config
↓
restart ngay
nếu chưa kiểm tra syntax.
Giả sử domain:
example.com
DNS cần trỏ:
example.com → IP VPS
Ví dụ:
A
example.com
123.123.123.123
Và:
A
www
123.123.123.123
Sau đó Nginx:
server_name example.com www.example.com;
Nginx cho phép:
VPS
│
├── example.com
│ └── /var/www/example
│
├── blog.com
│ └── /var/www/blog
│
└── shop.com
└── /var/www/shop
Mỗi website có một server block.
Ví dụ:
/etc/nginx/sites-available/
├── example
├── blog
└── shop
và:
/etc/nginx/sites-enabled/
├── example
├── blog
└── shop
Đây là một trong những lý do Nginx rất phù hợp với VPS.
Website production nên sử dụng:
HTTPS
thay vì:
HTTP
Mô hình:
Browser
│
HTTPS :443
▼
Nginx
│
▼
PHP-FPM
│
▼
Laravel
Thông thường có thể sử dụng Let's Encrypt + Certbot để cấp SSL certificate.
Sau khi cấu hình SSL:
http://example.com
có thể redirect sang:
https://example.com
Trong Blog CMS của chúng ta có upload:
Post Image
Laravel public disk mặc định lưu tại:
storage/app/public
Để browser truy cập được, tạo symbolic link:
php artisan storage:link
Khi đó:
storage/app/public
│
│ symbolic link
▼
public/storage
Laravel documentation cũng sử dụng storage:link cho public disk.
Ví dụ database lưu:
posts/abc.jpg
thì URL có thể là:
Storage::url($post->image)
hoặc:
asset('storage/' . $post->image)
Blog CMS của chúng ta có upload image.
Nginx cũng có giới hạn kích thước request.
Ví dụ:
client_max_body_size 10M;
Nếu website cho phép upload file lớn hơn mặc định, có thể cấu hình:
server {
...
client_max_body_size 10M;
}
Sau đó:
sudo nginx -t
và:
sudo systemctl reload nginx
Tuy nhiên còn phải kiểm tra giới hạn của PHP:
upload_max_filesize
post_max_size
Ví dụ:
upload_max_filesize = 10M
post_max_size = 12M
Không nên chỉ sửa Nginx mà quên PHP.
Nếu upload file quá lớn, Nginx có thể trả:
413 Request Entity Too Large
Nguyên nhân thường liên quan:
client_max_body_size
Ví dụ:
client_max_body_size 2M;
nhưng người dùng upload:
5MB
Nginx có thể chặn request trước khi Laravel nhận được.
Log thường nằm trong:
/var/log/nginx/
Ví dụ:
access.log
error.log
Xem error log:
sudo tail -f /var/log/nginx/error.log
Xem access log:
sudo tail -f /var/log/nginx/access.log
Khi website gặp lỗi:
502
403
404
413
hãy kiểm tra log.
Một lỗi rất phổ biến:
502 Bad Gateway
Nếu:
Nginx
│
X
PHP-FPM
thì Nginx không thể giao tiếp với PHP-FPM.
Kiểm tra:
sudo systemctl status php8.3-fpm
Kiểm tra socket:
ls /var/run/php/
Ví dụ socket thực tế là:
php8.3-fpm.sock
nhưng Nginx lại cấu hình:
fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
thì sẽ lỗi.
Nếu gặp:
403 Forbidden
có thể kiểm tra:
quyền thư mục;
owner;
Nginx configuration;
public/;
file permissions.
Laravel cần web server có quyền ghi vào:
storage/
và:
bootstrap/cache/
Laravel deployment documentation cũng yêu cầu các thư mục này phải writable bởi web server process.
Nếu:
/blog
trả:
404
hãy kiểm tra:
location / {
try_files $uri $uri/ /index.php?$query_string;
}
Nếu thiếu:
/index.php
Laravel routing có thể không hoạt động đúng.
Sau đó kiểm tra route:
php artisan route:list
Nếu Laravel chạy nhưng:
CSS
JS
Image
không hiển thị, kiểm tra:
public/build
Nếu dùng Vite, trên server cần build:
npm install
npm run build
Sau đó kiểm tra:
public/build/
Laravel không tự động biến source frontend thành production assets chỉ vì Nginx được cấu hình.
Một hiểu nhầm phổ biến:
Nginx = Laravel
Không đúng.
Nginx chỉ là web server.
Kiến trúc:
Nginx
↓
PHP-FPM
↓
Laravel
↓
Application
↓
Database
Mỗi thành phần có nhiệm vụ riêng.
Một server Laravel production hoàn chỉnh có thể nhìn như sau:
INTERNET
│
▼
┌─────────┐
│ Nginx │
└────┬────┘
│
▼
┌───────────┐
│ PHP-FPM │
└─────┬─────┘
│
▼
┌─────────────┐
│ Laravel 13 │
└──────┬──────┘
│
┌───────────┼───────────┐
▼ ▼ ▼
MySQL Storage Cache
Đây chính là kiến trúc cơ bản mà chúng ta đang xây dựng cho Blog CMS.
Một configuration tương đối hoàn chỉnh:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
root /var/www/blog/public;
index index.php;
charset utf-8;
client_max_body_size 10M;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico {
access_log off;
log_not_found off;
}
location = /robots.txt {
access_log off;
log_not_found off;
}
location ~ ^/index\.php(/|$) {
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_buffer_size 32k;
fastcgi_buffers 8 32k;
fastcgi_busy_buffers_size 64k;
fastcgi_hide_header X-Powered-By;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
Laravel 13's official deployment example also includes security headers such as X-Frame-Options and X-Content-Type-Options, the Laravel try_files rule, PHP-FPM configuration and hidden-file protection.
Quy trình:
sudo nginx -t
Nếu:
syntax is ok
test is successful
thì:
sudo systemctl reload nginx
Kiểm tra:
sudo systemctl status nginx
Sau đó mở:
https://example.com
Trước khi xem deployment là hoàn thành:
[ ] PHP >= 8.3
[ ] PHP-FPM đang chạy
[ ] Nginx đang chạy
[ ] root trỏ tới /public
[ ] server_name đúng domain
[ ] try_files đúng
[ ] fastcgi_pass đúng PHP-FPM socket
[ ] SCRIPT_FILENAME đúng
[ ] .env không public
[ ] storage/ có quyền ghi
[ ] bootstrap/cache có quyền ghi
[ ] php artisan storage:link
[ ] npm run build
[ ] nginx -t thành công
[ ] nginx reload
[ ] HTTPS hoạt động
[ ] APP_DEBUG=false
Kiểm tra Nginx:
nginx -v
Kiểm tra PHP-FPM:
sudo systemctl status php8.3-fpm
Tìm PHP-FPM socket:
ls /var/run/php/
Tạo server block:
/etc/nginx/sites-available/blog
và cấu hình Laravel:
/var/www/blog/public
Kiểm tra:
sudo nginx -t
Reload:
sudo systemctl reload nginx
Mở Blog CMS và kiểm tra:
/
├── Login
├── Register
├── Blog
├── Category
├── Post
└── Admin
Upload một hình ảnh vào Blog CMS.
Kiểm tra:
storage/app/public
và:
public/storage
Sau bài này chúng ta đã hiểu được:
Nginx
là gì.
PHP-FPM
là gì.
Và quan trọng nhất:
Nginx
↓
PHP-FPM
↓
Laravel 13
Chúng ta đã biết:
cài và kiểm tra Nginx;
kiểm tra PHP-FPM;
tạo Server Block;
cấu hình server_name;
cấu hình root;
trỏ Nginx vào public/;
cấu hình try_files;
cấu hình PHP-FPM;
bảo vệ file ẩn;
cấu hình upload;
cấu hình domain;
chạy nhiều website trên một VPS;
đọc Nginx log;
xử lý lỗi 403;
xử lý lỗi 404;
xử lý lỗi 502;
xử lý lỗi 413;
kết hợp Nginx với Laravel Storage;
kiểm tra configuration bằng nginx -t;
reload Nginx.
Kiến trúc chúng ta đang hướng tới:
DOMAIN
│
▼
┌──────────┐
│ Nginx │
└────┬─────┘
│
▼
┌───────────┐
│ PHP-FPM │
└─────┬─────┘
│
▼
┌─────────────┐
│ Laravel 13 │
└──────┬──────┘
│
┌───────────┼───────────┐
▼ ▼ ▼
MySQL Storage Cache
Đây là nền tảng rất quan trọng trước khi chúng ta đi tiếp vào Queue Worker và Scheduler.
Có 5 điều đặc biệt quan trọng:
Nginx → PHP-FPM → Laravel
publicroot /var/www/blog/public;
try_filestry_files $uri $uri/ /index.php?$query_string;
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
sudo nginx -t
sudo systemctl reload nginx
Bài tiếp theo: Bài 48 — Queue Worker & Scheduler
Chúng ta sẽ đưa những phần chạy nền của Blog CMS lên production:
Laravel Queue
│
▼
Queue Worker
│
├── Send Mail
├── Notification
├── Job
└── Background Task
Scheduler
│
├── Cron
├── Daily Task
├── Cleanup
└── Scheduled Job
Đây là bước biến một Laravel project từ website đơn giản thành một ứng dụng production thực tế.
x1
Cập nhật: 2026-09-24T20:25:24.273+07:00
Ở Bài 45, chúng ta đã đưa Laravel 13 lên Shared Hosting.
Trong bài này, chúng ta tiến thêm một bước:
Shared Hosting
↓
VPS
VPS cho phép chúng ta tự quản lý server ở mức thấp hơn:
VPS
│
├── Ubuntu
├── Nginx
├── PHP
├── PHP-FPM
├── MySQL
├── Composer
├── Git
├── SSL
└── Laravel 13
Đây là mô hình rất quan trọng nếu muốn làm Laravel ở môi trường production thực tế
VPS là:
Virtual Private Server
Hiểu đơn giản, VPS là một máy chủ ảo có:
CPU
RAM
SSD
hệ điều hành
IP riêng
quyền quản trị server
Ví dụ:
Internet
│
▼
Public IP
│
▼
VPS
│
├── Nginx
├── PHP 8.3
├── MySQL
└── Laravel 13
Khác với Shared Hosting:
Shared Hosting
→ Nhà cung cấp quản lý phần lớn server
còn:
VPS
→ Bạn quản lý phần lớn server
| Shared Hosting | VPS |
|---|---|
| Dễ sử dụng | Khó hơn |
| Control Panel | SSH/Terminal |
| Ít quyền hệ thống | Nhiều quyền |
| Ít phải cấu hình | Tự cấu hình |
| Tài nguyên chia sẻ | Tài nguyên VPS riêng |
| Phù hợp website nhỏ | Phù hợp ứng dụng cần kiểm soát server |
| Không cần biết Linux quá sâu | Cần biết Linux |
| Có thể giới hạn Composer/CLI | Chủ động Composer/CLI |
Có thể hình dung:
Shared Hosting
↓
"Thuê một căn phòng"
VPS
↓
"Thuê một căn nhà và tự quản lý"
Đối với Blog CMS Laravel 13, một VPS cơ bản có thể gồm:
Ubuntu
↓
Nginx
↓
PHP 8.3+
↓
PHP-FPM
↓
Laravel 13
↓
MySQL
Ngoài ra:
Git
Composer
Node.js
npm
Certbot
cũng rất hữu ích.
Laravel 13 yêu cầu tối thiểu PHP 8.3 và một số PHP extension như Ctype, cURL, DOM, Fileinfo, Mbstring, OpenSSL, PDO, Session, Tokenizer và XML.
Sau khi hoàn thành bài này, server sẽ có dạng:
INTERNET
│
▼
example.com
│
▼
Nginx
│
▼
public/index.php
│
▼
Laravel 13
│
┌──────────────┼──────────────┐
│ │ │
▼ ▼ ▼
MySQL Storage Cache
Nginx nhận request:
https://example.com
sau đó chuyển request PHP tới:
PHP-FPM
và Laravel xử lý request.
Giả sử chúng ta thuê một VPS Linux mới.
Ví dụ:
OS:
Ubuntu
RAM:
2 GB
CPU:
2 vCPU
Disk:
40 GB SSD
Public IP:
203.0.113.10
Đây chỉ là ví dụ.
Thông số thực tế tùy project.
SSH là phương thức kết nối từ máy tính tới server.
Ví dụ trên Windows:
ssh root@203.0.113.10
Trong đó:
ssh
↓
giao thức SSH
root
↓
username
203.0.113.10
↓
IP VPS
Sau khi đăng nhập:
local computer
│
│ SSH
▼
VPS
Ban đầu nhà cung cấp VPS có thể cho:
root
Nhưng không nên sử dụng root cho mọi thao tác hàng ngày.
Tạo user riêng:
adduser deploy
Sau đó cho user quyền sudo:
usermod -aG sudo deploy
Kiểm tra:
groups deploy
Kết quả có:
sudo
User:
deploy
sẽ được dùng để quản lý application thay vì đăng nhập root liên tục.
Thay vì chỉ dùng password:
SSH Password
có thể sử dụng:
SSH Key
Mô hình:
Computer
│
├── private key
└── public key
│
▼
VPS
Private key:
Không được chia sẻ.
Public key:
Có thể đưa lên server.
Sau khi đăng nhập:
sudo apt update
sau đó:
sudo apt upgrade -y
Mục đích:
Package list
↓
Update
↓
Security patches
↓
System packages
Không nên bỏ qua việc cập nhật server.
lsb_release -a
hoặc:
cat /etc/os-release
Ví dụ:
Ubuntu
24.04 LTS
Tên phiên bản không quan trọng bằng việc:
Hệ điều hành phải còn được hỗ trợ và có package phù hợp với PHP 8.3+.
sudo apt install nginx -y
Kiểm tra:
systemctl status nginx
Nếu thấy:
active (running)
Nginx đang chạy.
Mở browser:
http://203.0.113.10
Nếu thấy trang mặc định Nginx:
Welcome to nginx!
thì Nginx đã hoạt động.
Nginx là web server.
Request:
Browser
│
▼
Nginx
│
▼
PHP-FPM
│
▼
Laravel
Ví dụ:
GET /blog
Nginx nhận request.
Sau đó request được chuyển cho Laravel xử lý.
Laravel 13 yêu cầu:
PHP >= 8.3
Kiểm tra PHP:
php -v
Nếu server có PHP 8.3:
PHP 8.3.x
là đạt yêu cầu tối thiểu.
PHP-FPM:
PHP FastCGI Process Manager
Nginx không trực tiếp chạy PHP như:
Nginx → PHP source
Mà thường:
Browser
↓
Nginx
↓
PHP-FPM
↓
PHP
↓
Laravel
Ví dụ socket:
/var/run/php/php8.3-fpm.sock
Laravel 13 deployment documentation cũng sử dụng PHP-FPM trong cấu hình Nginx mẫu.
Laravel 13 cần các extension cần thiết.
Ví dụ Ubuntu:
sudo apt install -y \
php8.3-fpm \
php8.3-mysql \
php8.3-curl \
php8.3-mbstring \
php8.3-xml \
php8.3-zip \
php8.3-bcmath \
php8.3-intl
Sau đó:
php -m
Kiểm tra các extension.
Lưu ý:
Tên package PHP có thể khác tùy phiên bản Ubuntu/repository.
Không nên copy một lệnh cài PHP từ một server Ubuntu khác mà không kiểm tra repository của server hiện tại.
sudo systemctl status php8.3-fpm
Nếu:
active (running)
thì PHP-FPM đang hoạt động.
Khởi động cùng hệ thống:
sudo systemctl enable php8.3-fpm
sudo apt install mysql-server -y
Kiểm tra:
sudo systemctl status mysql
Nếu:
active (running)
MySQL đang hoạt động.
Đăng nhập MySQL:
sudo mysql
Tạo database:
CREATE DATABASE blog
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
Tạo user:
CREATE USER 'blog_user'@'localhost'
IDENTIFIED BY 'StrongPasswordHere';
Cấp quyền:
GRANT ALL PRIVILEGES
ON blog.*
TO 'blog_user'@'localhost';
Sau đó:
FLUSH PRIVILEGES;
Thoát:
EXIT;
.envKhông nên:
DB_USERNAME=root
cho application production nếu không cần thiết.
Nên:
DB_DATABASE=blog
DB_USERNAME=blog_user
DB_PASSWORD=********
Mô hình:
Laravel
│
▼
blog_user
│
▼
blog database
giúp giới hạn quyền của application.
Kiểm tra:
composer --version
Nếu chưa có Composer thì cài theo hướng dẫn chính thức của Composer.
Sau khi cài:
composer --version
Ví dụ:
Composer version 2.x
sudo apt install git -y
Kiểm tra:
git --version
Git giúp chúng ta triển khai:
GitHub
↓
VPS
↓
Laravel
thay vì phải upload ZIP thủ công mỗi lần cập nhật.
Ví dụ:
sudo mkdir -p /var/www/blog
Gán owner:
sudo chown -R deploy:deploy /var/www/blog
Bây giờ:
/var/www/blog
là nơi chứa Laravel application.
Nếu source nằm trên GitHub:
cd /var/www
sau đó:
git clone https://github.com/username/blog.git
Kết quả:
/var/www/blog
có:
app/
bootstrap/
config/
database/
public/
resources/
routes/
.envRepository:
blog/
├── app/
├── bootstrap/
├── config/
├── database/
├── public/
├── resources/
├── routes/
├── .env.example
└── composer.json
Không nên commit:
.env
Production .env phải được tạo trực tiếp trên server.
.envcd /var/www/blog
Copy:
cp .env.example .env
Sau đó:
nano .env
Ví dụ:
APP_NAME="Blog CMS"
APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=https://example.com
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=blog
DB_USERNAME=blog_user
DB_PASSWORD=StrongPasswordHere
Nếu đây là project mới trên server:
php artisan key:generate
Nếu project đã có dữ liệu production và đã sử dụng một APP_KEY trước đó:
Không được tự ý tạo key mới.
Đối với project đang triển khai lần đầu:
php artisan key:generate
sẽ tạo:
APP_KEY=base64:...
Trong project:
cd /var/www/blog
chạy:
composer install \
--no-dev \
--optimize-autoloader
Mục đích:
composer.json
+
composer.lock
↓
vendor/
Sau đó:
vendor/
được tạo.
Nếu server dùng để build frontend:
npm install
Sau đó:
npm run build
Kết quả:
public/
└── build/
Tuy nhiên production server không nhất thiết phải giữ:
node_modules/
nếu bạn đã build asset ở CI/local và chỉ deploy public/build.
Sau khi .env đã cấu hình:
php artisan migrate --force
Laravel sẽ tạo database schema:
users
categories
posts
personal_access_tokens
...
Đối với Blog CMS của chúng ta, đây là lúc toàn bộ migration đã học từ đầu khóa học được đưa lên VPS.
Nếu cần dữ liệu ban đầu:
php artisan db:seed --force
Ví dụ:
categories
admin user
default settings
Nhưng tuyệt đối phải kiểm tra Seeder trước khi chạy production.
Không chạy Seeder có thao tác kiểu:
Model::truncate();
nếu database đã có dữ liệu thật.
Tạo symbolic link:
php artisan storage:link
Mô hình:
public/storage
│
▼
storage/app/public
Blog CMS có upload image nên bước này rất quan trọng.
Laravel deployment/filesystem documentation sử dụng public/storage để truy cập các file của public disk.
Laravel cần quyền ghi vào:
storage/
và:
bootstrap/cache/
Laravel 13 deployment documentation yêu cầu web server process có quyền ghi vào hai thư mục này.
Có thể thiết lập owner phù hợp:
sudo chown -R deploy:www-data /var/www/blog
Sau đó:
sudo chmod -R ug+rwx \
/var/www/blog/storage \
/var/www/blog/bootstrap/cache
Không nên giải quyết permission bằng:
chmod -R 777 /var/www/blog
Đây là phần quan trọng nhất của bài.
Tạo:
sudo nano /etc/nginx/sites-available/blog
Ví dụ:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
root /var/www/blog/public;
index index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico {
access_log off;
log_not_found off;
}
location = /robots.txt {
access_log off;
log_not_found off;
}
location ~ ^/index\.php(/|$) {
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME
$realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
Đây là cấu hình dựa trên cấu hình Nginx deployment mà Laravel cung cấp cho Laravel 13. Điểm quan trọng là root phải là /public và request được chuyển về public/index.php.
root phải là /public?Sai:
root /var/www/blog;
Đúng:
root /var/www/blog/public;
Vì Laravel có:
/var/www/blog/
│
├── .env
├── artisan
├── composer.json
├── app/
├── config/
└── public/
└── index.php
Nếu web root là:
/var/www/blog
thì server có thể expose những file không nên public.
Laravel cũng cảnh báo không phục vụ application từ project root vì có thể làm lộ các file cấu hình nhạy cảm.
Tạo symbolic link:
sudo ln -s \
/etc/nginx/sites-available/blog \
/etc/nginx/sites-enabled/blog
Nếu muốn xóa site mặc định:
sudo rm /etc/nginx/sites-enabled/default
Sau đó kiểm tra:
sudo nginx -t
Nếu:
syntax is ok
test is successful
thì reload:
sudo systemctl reload nginx
Giả sử VPS có IP:
203.0.113.10
DNS:
example.com
tạo:
A
@
203.0.113.10
và:
A
www
203.0.113.10
Mô hình:
example.com
│
▼
DNS
│
▼
203.0.113.10
│
▼
Nginx
│
▼
/var/www/blog/public
Trước khi cấu hình domain hoàn chỉnh, có thể kiểm tra:
http://203.0.113.10
Nếu Nginx đã nhận đúng server block và DNS/domain được cấu hình phù hợp, request sẽ đi vào Laravel.
Production nên sử dụng:
https://example.com
thay vì:
http://example.com
Một lựa chọn phổ biến trên Ubuntu + Nginx là Certbot.
Sau khi cài Certbot phù hợp với hệ điều hành:
sudo certbot --nginx
Certbot có thể cấu hình certificate và HTTPS cho Nginx.
Sau đó kiểm tra:
https://example.com
.env ProductionCuối cùng:
APP_ENV=production
APP_DEBUG=false
APP_URL=https://example.com
Đặc biệt:
APP_DEBUG=false
Laravel cảnh báo không bật debug trong production vì có thể làm lộ thông tin cấu hình nhạy cảm.
Sau khi application đã hoạt động:
php artisan optimize
Laravel 13 cung cấp optimize để cache các thành phần production như configuration, events, routes và views.
Có thể kiểm tra:
php artisan optimize:clear
khi cần xóa cache trong quá trình troubleshooting.
Mở:
https://example.com
Sau đó test:
/
/login
/register
/blog
/admin/users
/api/posts
và:
/up
Laravel 13 có health route /up, trả HTTP 200 nếu application boot thành công và HTTP 500 nếu application gặp exception khi boot.
Trên VPS:
php artisan migrate:status
Ví dụ:
Migration name Ran?
------------------------------------------------
create_users_table Yes
create_categories_table Yes
create_posts_table Yes
create_personal_access_tokens_table Yes
Nếu tất cả:
Yes
database schema đã được triển khai.
Nếu Blog CMS sử dụng queue:
php artisan queue:work
có thể chạy thử.
Nhưng:
Không nên dùng terminal SSH để chạy worker production lâu dài.
Nếu SSH đóng:
queue worker
↓
process kết thúc
Do đó production cần process monitor.
Phần này sẽ được học kỹ hơn ở:
Bài 48 — Queue Worker & Scheduler
Laravel:
storage/logs/
Ví dụ:
storage/logs/laravel.log
Có thể xem:
tail -f storage/logs/laravel.log
Khi website:
500
đừng đoán.
Hãy kiểm tra:
Laravel log
Nginx log
PHP-FPM log
Log thường nằm trong:
/var/log/nginx/
Ví dụ:
sudo tail -f /var/log/nginx/error.log
và:
sudo tail -f /var/log/nginx/access.log
Mô hình debug:
Browser
↓
Nginx
↓
PHP-FPM
↓
Laravel
↓
MySQL
Phải xác định lỗi nằm ở tầng nào.
Nếu Nginx báo:
502 Bad Gateway
kiểm tra PHP-FPM:
sudo systemctl status php8.3-fpm
Sau đó:
sudo journalctl -u php8.3-fpm
Một nguyên nhân thường gặp:
Nginx
↓
socket PHP-FPM
↓
SAI đường dẫn
Ví dụ Nginx:
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
nhưng server thực tế dùng socket khác.
Kiểm tra:
ls /var/run/php/
Nếu:
502 Bad Gateway
không có nghĩa Laravel chắc chắn bị lỗi.
Có thể là:
Nginx
↓
PHP-FPM
X
Kiểm tra:
systemctl status php8.3-fpm
và:
ls /var/run/php/
Sau đó đối chiếu:
fastcgi_pass ...
Kiểm tra:
Document Root
phải là:
/var/www/blog/public
không phải:
/var/www/blog
Sau đó kiểm tra:
Permission
Owner
Nginx configuration
Nếu:
/
chạy được nhưng:
/blog
404:
kiểm tra:
location / {
try_files $uri $uri/ /index.php?$query_string;
}
Đây là phần giúp request không phải file tĩnh được chuyển vào Laravel.
Ví dụ:
/blog
↓
Nginx
↓
index.php
↓
Laravel Router
↓
BlogController
Ví dụ:
file_put_contents(...):
Permission denied
Kiểm tra:
ls -la storage
và:
ls -la bootstrap/cache
Sau đó đảm bảo web server có quyền ghi.
Laravel yêu cầu quyền ghi cho hai thư mục:
storage/
bootstrap/cache/
Sau khi triển khai:
VPS
│
├── /var/www/blog
│ │
│ ├── app/
│ ├── bootstrap/
│ ├── config/
│ ├── database/
│ ├── public/
│ │ ├── index.php
│ │ ├── build/
│ │ └── storage
│ ├── resources/
│ ├── routes/
│ ├── storage/
│ ├── vendor/
│ ├── .env
│ ├── artisan
│ └── composer.json
│
├── Nginx
│
├── PHP-FPM
│
└── MySQL
Tóm lại:
1. Tạo VPS
↓
2. SSH
↓
3. Update Ubuntu
↓
4. Tạo deploy user
↓
5. Cài Nginx
↓
6. Cài PHP 8.3+
↓
7. Cài PHP-FPM
↓
8. Cài MySQL
↓
9. Cài Composer
↓
10. Cài Git
↓
11. Clone project
↓
12. Tạo .env
↓
13. composer install
↓
14. npm run build
↓
15. migrate
↓
16. storage:link
↓
17. Permission
↓
18. Nginx
↓
19. DNS
↓
20. HTTPS
↓
21. optimize
↓
22. Test
Một trong những ưu điểm lớn của VPS là có thể triển khai bằng Git.
Ví dụ lần đầu:
cd /var/www
git clone https://github.com/username/blog.git
cd blog
composer install --no-dev --optimize-autoloader
npm install
npm run build
php artisan migrate --force
php artisan storage:link
php artisan optimize
Lần cập nhật sau:
cd /var/www/blog
git pull
composer install --no-dev --optimize-autoloader
npm install
npm run build
php artisan migrate --force
php artisan optimize
Đây đã bắt đầu giống một quy trình deployment thực tế.
git pull mù quángProduction không phải nơi để:
code thử
↓
git pull
↓
hy vọng
Quy trình tốt hơn:
LOCAL
↓
Code
↓
Test
↓
Commit
↓
Push
↓
Git
↓
VPS
↓
Deploy
↓
Test
Đặc biệt khi có:
Database migration
Config change
Dependency change
phải biết chính xác deployment đang thay đổi điều gì.
Ở bài đầu tiên về VPS, chúng ta tập trung vào:
VPS
Nginx
PHP-FPM
MySQL
Laravel
HTTPS
Chưa cần xây dựng:
Load Balancer
Multiple VPS
Blue/Green Deployment
CI/CD phức tạp
Kubernetes
Đó là những vấn đề nâng cao.
Mục tiêu của bài:
Có thể tự triển khai một Laravel 13 Blog CMS lên một VPS và hiểu từng thành phần đang làm gì.
Sau hai bài 45 và 46:
Bài 45
Shared Hosting
và:
Bài 46
VPS
chúng ta có:
DEPLOY LARAVEL
│
┌────────┴────────┐
│ │
Shared Hosting VPS
│ │
Control Panel SSH
│ │
Ít quyền hệ thống Toàn quyền hơn
│ │
Dễ triển khai Tự quản lý
VPS phù hợp khi bạn cần:
SSH
Composer
Cron
Queue Worker
Redis
Supervisor
Nginx
Custom PHP
Background Process
Đặc biệt:
Laravel Queue
Scheduler
API
WebSocket
sẽ cần nhiều quyền kiểm soát server hơn.
Không phải website nào cũng cần VPS.
Ví dụ:
Blog nhỏ
Website giới thiệu
Landing Page
CMS nhỏ
có thể chạy tốt trên Shared Hosting.
Không nên nghĩ:
VPS = luôn tốt hơn
Mà nên nghĩ:
Nhu cầu
↓
Kiến trúc
↓
Loại hosting
Tạo một VPS Linux.
Cài:
Nginx
PHP 8.3+
PHP-FPM
MySQL
Composer
Git
Tạo:
/var/www/blog
và clone Blog CMS.
Cấu hình:
.env
với:
APP_ENV=production
APP_DEBUG=false
Tạo database:
blog
và user:
blog_user
Chạy:
composer install --no-dev --optimize-autoloader
Chạy:
php artisan migrate --force
Chạy:
php artisan storage:link
Cấu hình Nginx:
/var/www/blog/public
làm web root.
Trỏ domain về VPS.
Cài HTTPS.
Kiểm tra:
https://example.com
và:
https://example.com/up
Virtual Private Server
Remote Server Management
Web Server
PHP Process Manager
Database
PHP Dependency Manager
Source Code Management
project/public
APP_DEBUG=false
storage/
bootstrap/cache/
php artisan optimize
Ở Bài 45:
Laravel 13
↓
Shared Hosting
Ở Bài 46:
Laravel 13
↓
VPS
↓
Ubuntu
↓
Nginx
↓
PHP-FPM
↓
MySQL
Chúng ta đã biết cách:
SSH vào VPS
↓
Cài môi trường
↓
Đưa source lên server
↓
Cấu hình .env
↓
Cấu hình Database
↓
Composer
↓
Migration
↓
Storage
↓
Nginx
↓
DNS
↓
HTTPS
↓
Optimize
↓
Laravel 13 chạy production
Điểm quan trọng nhất cần nhớ:
Laravel 13
│
▼
/var/www/blog
│
▼
public
│
▼
Nginx
│
▼
PHP-FPM
│
▼
Laravel
│
▼
MySQL
Và không được cấu hình Nginx phục vụ project root:
❌ /var/www/blog
mà phải:
✅ /var/www/blog/public
Laravel 13 cũng yêu cầu PHP 8.3 trở lên, đồng thời storage và bootstrap/cache phải writable đối với web server process.
Chúng ta đã biết VPS gồm những thành phần nào và cách đưa Laravel 13 lên VPS.
Nhưng trong bài này chúng ta mới sử dụng Nginx ở mức cấu hình cơ bản.
Bài tiếp theo sẽ đi sâu vào chính thành phần này:
Chúng ta sẽ học:
Nginx là gì?
↓
Server Block
↓
Document Root
↓
location
↓
try_files
↓
PHP-FPM
↓
FastCGI
↓
Domain
↓
HTTPS
↓
Laravel 13
và quan trọng nhất là hiểu từng dòng trong file cấu hình Nginx Laravel, thay vì chỉ copy một đoạn config rồi chạy.
x1
Cập nhật: 2026-09-22T15:58:04.904+07:00
Sau khi hoàn thành:
Bài 41 — REST API
Bài 42 — API Resource
Bài 43 — Sanctum
Bài 44 — API Authentication
chúng ta đã hoàn thành phần REST API.
Bây giờ bắt đầu:
Và bài đầu tiên là:
Deploy Laravel 13 lên Shared Hosting
Mục tiêu của bài này là đưa project Blog CMS từ:
Máy tính cá nhân
↓
Laragon
↓
127.0.0.1:8000
lên:
Shared Hosting
↓
domain.com
↓
Laravel 13
↓
MySQL
Shared Hosting là loại hosting trong đó nhiều website cùng sử dụng tài nguyên của một server.
Ví dụ:
SERVER
│
├── Website A
├── Website B
├── Website C
├── Blog Laravel
└── Website D
Nhà cung cấp hosting thường quản lý sẵn:
Web server
PHP
MySQL/MariaDB
SSL
Domain
FTP
File Manager
Database
Cron Job
Người dùng chủ yếu quản lý website của mình.
Có.
Laravel có thể chạy trên Shared Hosting nếu hosting đáp ứng các yêu cầu cần thiết.
Đối với Laravel 13, server production cần PHP >= 8.3 cùng các extension PHP cần thiết như:
Ctype
cURL
DOM
Fileinfo
Filter
Hash
Mbstring
OpenSSL
PCRE
PDO
Session
Tokenizer
XML
Đây là các yêu cầu được Laravel 13 liệt kê trong tài liệu deployment chính thức.
Vì vậy trước khi mua hosting:
KHÔNG chỉ hỏi:
"Hosting có PHP không?"
mà phải kiểm tra:
PHP 8.3+
MySQL
PDO
Mbstring
OpenSSL
Fileinfo
Composer hoặc cách cài dependencies
Cron Job
Document Root
Với PHP thuần, chúng ta có thể upload:
index.php
config.php
vào:
public_html/
và chạy.
Laravel có cấu trúc:
blog/
│
├── app/
├── bootstrap/
├── config/
├── database/
├── public/
├── resources/
├── routes/
├── storage/
├── vendor/
├── .env
└── artisan
Không nên biến toàn bộ project thành thư mục public.
Laravel được thiết kế để web server phục vụ ứng dụng thông qua:
public/index.php
Laravel cũng cảnh báo không nên phục vụ application từ project root vì có thể làm lộ các file cấu hình và dữ liệu nhạy cảm.
Giả sử project nằm:
/home/user/blog/
thì:
/home/user/blog/
│
├── app/
├── bootstrap/
├── config/
├── database/
├── public/
│ ├── index.php
│ ├── build/
│ └── ...
├── resources/
├── routes/
├── storage/
├── vendor/
├── .env
└── artisan
Web server phải trỏ vào:
/home/user/blog/public
chứ không phải:
/home/user/blog
Sơ đồ:
DOMAIN
│
▼
/home/user/blog/public
│
▼
index.php
│
▼
Laravel 13
Document Root là thư mục mà web server dùng để phục vụ website.
Ví dụ:
example.com
↓
Document Root
↓
/home/user/blog/public
Khi người dùng truy cập:
https://example.com
web server sẽ tìm:
public/index.php
Laravel xử lý request từ đó.
Không nên:
public_html/
├── app/
├── bootstrap/
├── config/
├── database/
├── resources/
├── routes/
├── storage/
├── .env
├── artisan
└── index.php
Vì bạn đã đưa cả:
.env
artisan
composer.json
config/
vào vùng có khả năng được web server phục vụ.
Đây là cấu hình không an toàn.
Laravel yêu cầu web server trỏ tới thư mục public thay vì project root.
Trên Shared Hosting thường có hai tình huống.
Đây là cách sạch nhất.
Ví dụ:
/home/user/blog/public
được cấu hình làm Document Root của:
example.com
Khi đó:
example.com
↓
blog/public
Một số Shared Hosting cấu hình:
public_html/
là web root mặc định và không cho đổi.
Khi đó có thể tổ chức project theo kiểu:
/home/user/
│
├── blog/
│ ├── app/
│ ├── bootstrap/
│ ├── config/
│ ├── database/
│ ├── resources/
│ ├── routes/
│ ├── storage/
│ ├── vendor/
│ └── .env
│
└── public_html/
├── index.php
├── build/
├── storage/
└── ...
Nhưng cách này cần cấu hình index.php đúng đường dẫn tới project.
public/index.phpTrong Laravel, file:
public/index.php
là entry point của ứng dụng.
Nó khởi động Laravel:
<?php
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
require __DIR__.'/../vendor/autoload.php';
$app = require_once __DIR__.'/../bootstrap/app.php';
$app->handleRequest(Request::capture());
Điểm quan trọng là:
__DIR__.'/../vendor/autoload.php'
và:
__DIR__.'/../bootstrap/app.php'
đều giả định:
public/
├── index.php
│
└── ../
├── vendor/
└── bootstrap/
Vì vậy không được tùy tiện di chuyển index.php mà không điều chỉnh cấu trúc.
Trước khi upload:
php artisan optimize:clear
Sau đó kiểm tra:
php artisan migrate:status
Kiểm tra route:
php artisan route:list
Kiểm tra package:
composer show
Nếu sử dụng frontend:
npm run build
Laravel sử dụng Vite để build CSS/JavaScript thành các asset production. Laravel cũng hướng dẫn chạy npm install && npm run build khi chuẩn bị application.
Nếu project Blog CMS đang sử dụng Vite:
npm install
sau đó:
npm run build
Thông thường kết quả sẽ tạo:
public/build/
Ví dụ:
public/
└── build/
├── assets/
│ ├── app-xxxxx.css
│ └── app-xxxxx.js
└── manifest.json
Khi deploy:
Phải upload cả
public/build/.
Nếu quên:
CSS mất
JS mất
Layout vỡ
node_modules có cần upload không?Thông thường:
node_modules/
không cần upload lên hosting nếu bạn đã chạy:
npm run build
ở máy local.
Thứ cần deploy là asset đã build:
public/build/
Do đó:
node_modules/
có thể không cần đưa lên production.
vendor có cần upload không?Có hai phương án.
Upload:
composer.json
composer.lock
sau đó trên server:
composer install --no-dev --optimize-autoloader
Có thể chạy Composer ở máy local:
composer install --no-dev --optimize-autoloader
sau đó upload:
vendor/
lên hosting.
Tuy nhiên phải đảm bảo môi trường production tương thích.
Nếu hosting cung cấp SSH + Composer:
Nên ưu tiên chạy Composer trực tiếp trên server.
composer install khác composer updateKhi deploy:
composer install
thường phù hợp hơn:
composer update
Vì:
composer.lock
đã khóa phiên bản package.
Production nên cài đúng dependency đã được kiểm tra ở môi trường development.
Ví dụ:
composer install --no-dev --optimize-autoloader
.env từ máy local một cách máy mócĐây là lỗi rất thường gặp.
Local:
APP_ENV=local
APP_DEBUG=true
APP_URL=http://127.0.0.1:8000
Production không nên giữ nguyên.
Ví dụ production:
APP_ENV=production
APP_DEBUG=false
APP_URL=https://example.com
Database cũng phải đổi:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=blog_production
DB_USERNAME=blog_user
DB_PASSWORD=********
Laravel cần:
APP_KEY=
Nếu project đã có key:
APP_KEY=base64:xxxxxxxxxxxxxxxx
thì khi deploy project hiện tại:
Không tự ý tạo APP_KEY mới.
Nếu tạo key mới trên production:
php artisan key:generate
có thể khiến dữ liệu đã mã hóa bằng key cũ không còn giải mã được.
Đối với một project đã chạy thật:
APP_KEY
là thông tin rất quan trọng.
Trong cPanel hoặc control panel của hosting:
MySQL Databases
tạo:
Database
User
Password
Ví dụ:
Database:
user_blog
User:
user_blog
Password:
********
Sau đó .env:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=user_blog
DB_USERNAME=user_blog
DB_PASSWORD=********
Tên thực tế có thể được hosting tự động thêm prefix:
account_user_blog
account_blog
Do đó phải dùng đúng thông tin mà hosting cung cấp.
Local có database:
laravel13
Có thể export bằng:
phpMyAdmin
chọn:
Export
format:
SQL
Sau đó vào phpMyAdmin trên hosting:
Import
chọn:
database.sql
và import.
Có hai cách.
Export:
local.sql
Import:
production database
Phù hợp khi:
đã có dữ liệu
Nếu database production mới:
php artisan migrate --force
Laravel sẽ tạo bảng theo migration.
Tham số:
--force
cho phép migration chạy trong môi trường production mà không yêu cầu xác nhận tương tác.
Nếu cần dữ liệu mặc định:
php artisan db:seed --force
Ví dụ:
categories
admin
default settings
Nhưng phải cẩn thận với Seeder production.
Không nên chạy Seeder chứa:
User::truncate();
hoặc:
Post::truncate();
trên database thật.
Project:
blog/
├── app/
├── bootstrap/
├── config/
├── database/
├── public/
├── resources/
├── routes/
├── storage/
├── vendor/
├── artisan
├── composer.json
├── composer.lock
└── .env
Có thể upload thông qua:
FTP
SFTP
File Manager
SSH
Git
Nếu hosting hỗ trợ Git/SSH thì triển khai bằng Git thường thuận tiện hơn khi project tiếp tục cập nhật.
Nếu hosting cho phép đổi Document Root:
/home/account/
│
└── blog/
│
├── app/
├── bootstrap/
├── config/
├── database/
├── public/
├── resources/
├── routes/
├── storage/
├── vendor/
├── .env
└── artisan
Domain:
example.com
Document Root:
/home/account/blog/public
Đây là cấu trúc sạch nhất.
public_htmlVí dụ hosting bắt buộc:
public_html/
thì có thể:
/home/account/
│
├── blog/
│ ├── app/
│ ├── bootstrap/
│ ├── config/
│ ├── database/
│ ├── resources/
│ ├── routes/
│ ├── storage/
│ ├── vendor/
│ └── .env
│
└── public_html/
├── index.php
├── .htaccess
├── build/
└── storage/
index.php phải trỏ đúng tới:
../blog/vendor/autoload.php
và:
../blog/bootstrap/app.php
Ví dụ:
require __DIR__.'/../blog/vendor/autoload.php';
$app = require_once __DIR__.'/../blog/bootstrap/app.php';
Tuy nhiên đường dẫn chính xác phụ thuộc cấu trúc hosting.
Nếu hosting cho phép đổi Document Root, nên dùng cách đó thay vì phải sửa index.php.
.htaccessNếu Shared Hosting sử dụng Apache, Laravel thường cần rewrite request về:
index.php
Trong thư mục:
public/
Laravel có file:
.htaccess
để xử lý việc này trên Apache.
Cấu trúc:
public/
├── .htaccess
├── index.php
└── build/
Không nên lấy một .htaccess tùy tiện trên Internet rồi thay thế mà không hiểu nó làm gì.
Đây là phần rất dễ gặp lỗi khi deploy Blog CMS.
Local:
php artisan storage:link
tạo symbolic link:
public/storage
↓
storage/app/public
Laravel 13 cũng dùng cơ chế này cho public disk.
Ví dụ upload:
$post->image = $request
->file('image')
->store('posts', 'public');
Database có thể lưu:
posts/abc123.jpg
File thực tế:
storage/app/public/posts/abc123.jpg
URL:
/storage/posts/abc123.jpg
Nếu hosting có SSH:
php artisan storage:link
Laravel tạo:
public/storage
trỏ tới:
storage/app/public
Đây là cách chính thức được Laravel hướng dẫn cho public disk.
Nếu Shared Hosting không cho tạo symbolic link:
Cách xử lý phụ thuộc control panel/hosting.
Không nên tự ý biến:
storage/app/public
thành một thư mục public bằng cách di chuyển lung tung.
Sau deploy:
Admin
↓
Posts
↓
Create
↓
Upload Image
↓
Save
Kiểm tra:
storage/app/public/posts/
có file.
Sau đó browser:
https://example.com/storage/posts/image.jpg
phải hiển thị được.
Nếu database có:
posts/abc.jpg
nhưng browser:
404
thì kiểm tra:
public/storage
trước tiên.
Laravel cần ghi vào:
storage/
và:
bootstrap/cache/
Laravel 13 yêu cầu web server process có quyền ghi vào hai thư mục này.
Trên Linux có thể gặp:
Permission denied
khi Laravel cần:
write
cache
log
session
upload
Không nên tùy tiện:
chmod -R 777 .
Đây là cách xử lý quá rộng và không nên dùng như giải pháp mặc định.
Ví dụ:
storage/
├── app/
├── framework/
└── logs/
Laravel cần ghi:
storage/logs/
storage/framework/
storage/app/
Đặc biệt Blog CMS của chúng ta có:
Upload Image
nên:
storage/app/public/
cũng phải hoạt động bình thường.
.envProduction:
APP_NAME="Blog CMS"
APP_ENV=production
APP_KEY=base64:xxxxxxxx
APP_DEBUG=false
APP_URL=https://example.com
Database:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=account_blog
DB_USERNAME=account_blog
DB_PASSWORD=********
APP_DEBUG=falseĐây là một trong những cấu hình quan trọng nhất.
Không nên:
APP_DEBUG=true
trên production.
Nếu xảy ra exception mà:
APP_DEBUG=true
Laravel có thể hiển thị rất nhiều thông tin nội bộ.
Ví dụ:
Database
Paths
Environment
Stack Trace
Configuration
Laravel khuyến cáo production phải đặt:
APP_DEBUG=false
để tránh làm lộ thông tin nhạy cảm.
.envNếu application đã cache config:
php artisan config:clear
Sau đó có thể kiểm tra lại.
Khi production đã cấu hình chính xác:
php artisan config:cache
Laravel khuyến nghị cache configuration khi deploy production.
Laravel 13 cung cấp:
php artisan optimize
Lệnh này cache các thành phần phù hợp cho production.
Có thể dùng:
php artisan optimize
Sau khi deploy.
Laravel cũng cung cấp:
php artisan optimize:clear
để xóa các cache optimization.
Production:
php artisan config:cache
Sau khi chạy:
config/
được tối ưu thành cache.
Một lưu ý rất quan trọng:
Khi configuration đã được cache, không nên gọi
env()trực tiếp bên ngoài các file configuration.
Laravel ghi rõ sau khi config:cache, .env sẽ không được load như trong môi trường chưa cache và các lời gọi env() ngoài config có thể trả về null.
Đúng:
config('app.name');
Không nên:
env('APP_NAME');
trong controller/model/service.
Nếu application có nhiều route:
php artisan route:cache
Laravel tạo route cache để giảm thời gian đăng ký route.
Blog CMS của chúng ta hiện có:
Web Routes
Admin Routes
Blog Routes
API Routes
Authentication Routes
nên khi production ổn định có thể cache route.
Có thể chạy:
php artisan view:cache
Laravel sẽ precompile Blade views thay vì chờ compile khi request đầu tiên tới view.
Thay vì chạy từng lệnh:
php artisan config:cache
php artisan route:cache
php artisan view:cache
có thể sử dụng:
php artisan optimize
Laravel cung cấp optimize như một bước tổng hợp cho deployment production.
Với Blog CMS hiện tại, quy trình có thể là:
LOCAL
│
├── Code
├── Test
├── npm run build
├── composer install --no-dev
│
▼
UPLOAD
│
├── app
├── bootstrap
├── config
├── database
├── public
├── resources
├── routes
├── storage
├── vendor
├── artisan
└── ...
│
▼
SERVER
│
├── .env production
├── Database
├── Migration
├── Storage link
└── Permission
│
▼
OPTIMIZE
│
└── php artisan optimize
│
▼
DOMAIN
Giả sử project đã hoàn thành.
Local:
npm install
Build:
npm run build
Composer:
composer install --no-dev --optimize-autoloader
Upload project.
Tạo database trên hosting.
Import database hoặc migrate.
Tạo .env production.
Cấu hình:
APP_ENV=production
APP_DEBUG=false
APP_URL=https://example.com
Cấu hình database.
Tạo storage link:
php artisan storage:link
Optimize:
php artisan optimize
Mở:
https://example.com
Đừng đoán.
Kiểm tra:
storage/logs/
Laravel ghi log vào hệ thống logging của application.
Ví dụ:
storage/logs/laravel.log
Có thể kiểm tra:
tail -f storage/logs/laravel.log
nếu hosting có SSH.
Hoặc mở File Manager:
storage
└── logs
Class not foundVí dụ:
Class "App\Models\Post" not found
Kiểm tra:
composer install
hoặc:
composer dump-autoload
Nếu production:
composer install --no-dev --optimize-autoloader
Vite manifest not foundVí dụ:
Vite manifest not found
Kiểm tra:
public/build/
Có:
manifest.json
hay không.
Nếu không:
npm install
npm run build
sau đó upload:
public/build/
Nếu trang:
HTML
hiển thị nhưng:
CSS
mất:
kiểm tra:
public/build/
và:
@vite([
'resources/css/app.css',
'resources/js/app.js'
])
Ngoài ra kiểm tra:
APP_URL=https://example.com
và xem URL asset trong browser.
Kiểm tra:
storage/app/public/
và:
public/storage
Có link hay không.
Nếu có SSH:
php artisan storage:link
Sau đó kiểm tra:
https://example.com/storage/...
Laravel sử dụng public/storage làm symbolic link tới storage/app/public cho public disk.
Lỗi:
SQLSTATE[HY000]
kiểm tra:
DB_HOST=
DB_PORT=
DB_DATABASE=
DB_USERNAME=
DB_PASSWORD=
Đặc biệt:
DB_DATABASE
DB_USERNAME
trên Shared Hosting thường có prefix.
Ví dụ bạn nghĩ:
DB_DATABASE=blog
nhưng hosting thực tế:
DB_DATABASE=abc_blog
Chạy:
php artisan migrate:status
để kiểm tra migration.
Nếu database production đã có dữ liệu:
Không chạy
migrate:fresh.
Không dùng:
php artisan migrate:fresh
trên database production có dữ liệu quan trọng.
Lệnh này có thể xóa toàn bộ bảng.
Local:
APP_URL=http://127.0.0.1:8000
Production:
APP_URL=https://example.com
Điều này đặc biệt quan trọng với:
URL
Mail
Storage
Asset
Notification
Ví dụ:
asset('storage/'.$post->image)
có thể tạo URL dựa trên cấu hình application.
Production nên sử dụng:
https://
thay vì:
http://
Ví dụ:
https://example.com
SSL thường được Shared Hosting cung cấp thông qua:
Let's Encrypt
hoặc hệ thống SSL của nhà cung cấp.
Sau khi bật HTTPS, kiểm tra:
Login
Register
API
Image
CSS
JS
Form
đều hoạt động qua HTTPS.
Sau deploy, đừng chỉ mở:
/
Hãy test:
/
/login
/register
/blog
/blog/{slug}
/admin/users
/api/posts
và:
/api/login
API:
POST /api/login
lấy token.
Sau đó:
GET /api/user
với:
Authorization: Bearer TOKEN
Nếu hoạt động:
Laravel
+
Sanctum
+
Database
+
HTTPS
đã kết nối đúng.
Laravel 13 có health route mặc định:
/up
Nếu application khởi động bình thường, route này trả về:
200
Laravel cung cấp route này để monitoring application trong production.
Ví dụ:
https://example.com/up
Nếu hosting cho phép:
200 OK
là một dấu hiệu tốt rằng application đã boot thành công.
Trước khi kết luận deploy thành công:
☐ PHP >= 8.3
☐ Database đã tạo
☐ Database credentials đúng
☐ APP_KEY đúng
☐ APP_ENV=production
☐ APP_DEBUG=false
☐ APP_URL=https://domain.com
☐ Composer dependencies đã cài
☐ public/ là Document Root
☐ public/build/ tồn tại
☐ storage/ có quyền ghi
☐ bootstrap/cache/ có quyền ghi
☐ storage:link đã tạo
☐ Migration đã chạy
☐ Homepage hoạt động
☐ Login hoạt động
☐ Register hoạt động
☐ Admin hoạt động
☐ Upload Image hoạt động
☐ Blog hoạt động
☐ API hoạt động
☐ Sanctum hoạt động
☐ HTTPS hoạt động
☐ /up hoạt động
Đưa toàn bộ Laravel vào:
public_html
và cho web server chạy trực tiếp từ đó.
Có thể làm lộ file nội bộ.
Document Root:
project/public
Quên:
npm run build
CSS mất
JS mất
Quên:
php artisan storage:link
Upload thành công
nhưng:
Image 404
Để:
APP_DEBUG=true
trên production.
Có nguy cơ lộ thông tin nội bộ.
Database .env vẫn là:
DB_DATABASE=laravel13
DB_USERNAME=root
DB_PASSWORD=
Production không kết nối database.
Upload code nhưng quên:
vendor/
và hosting không có Composer.
Class not found
Upload code mới nhưng không clear/cache lại configuration.
Có thể gặp tình trạng:
.env đã sửa
nhưng Laravel vẫn sử dụng configuration cache cũ.
Khi đó:
php artisan optimize:clear
sau đó cấu hình lại và:
php artisan optimize
Đây là điều quan trọng nhất của bài.
Deploy Laravel không phải:
Upload
↓
Done
Mà là:
Code
↓
Dependencies
↓
Environment
↓
Database
↓
Document Root
↓
Storage
↓
Permission
↓
Frontend Build
↓
Cache
↓
HTTPS
↓
Testing
↓
Production
Sau bài này project có thể hình dung:
INTERNET
│
▼
example.com
│
▼
HTTPS / Web Server
│
▼
public/
│
index.php
│
▼
Laravel 13
│
┌────────────────┼────────────────┐
│ │ │
▼ ▼ ▼
Web Routes API Routes Storage
│ │ │
▼ ▼ ▼
Breeze Sanctum Uploaded Images
│ │
▼ ▼
Session Token
│ │
└────────┬───────┘
▼
MySQL
Đây đã là một ứng dụng Laravel thực tế thay vì chỉ chạy trên localhost.
PHP >= 8.3
project/public
project/
làm web root.
npm run build
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan storage:link
APP_ENV=production
APP_DEBUG=false
php artisan optimize
/up
Trong bài này chúng ta đã đưa Laravel 13 từ:
LOCALHOST
lên:
SHARED HOSTING
Đã học:
Shared Hosting
↓
PHP 8.3+
↓
Document Root
↓
public/
↓
.env
↓
MySQL
↓
Composer
↓
Vite
↓
Storage
↓
Permission
↓
HTTPS
↓
Optimization
Đối với Blog CMS của khóa học, quy trình quan trọng nhất cần nhớ là:
LOCAL
│
▼
Test Application
│
▼
npm run build
│
▼
composer install
│
▼
UPLOAD
│
▼
Configure .env
│
▼
Configure Database
│
▼
Run Migration
│
▼
php artisan storage:link
│
▼
php artisan optimize
│
▼
HTTPS
│
▼
TEST
│
▼
PRODUCTION
Và có một nguyên tắc đặc biệt quan trọng:
Laravel phải được phục vụ từ thư mục
public, không phải từ project root.
Laravel 13 cũng yêu cầu production có quyền ghi cho storage và bootstrap/cache, đồng thời khuyến nghị các bước caching/optimization phù hợp khi triển khai.
Chúng ta đã hoàn thành:
Bài 45 — Deploy Shared Hosting
Tiếp theo:
Lúc đó chúng ta sẽ không còn chỉ sử dụng hosting control panel nữa mà bắt đầu làm việc trực tiếp với server:
VPS
↓
Ubuntu
↓
SSH
↓
PHP
↓
Composer
↓
MySQL
↓
Nginx
↓
Laravel
Sau đó Bài 47 — Nginx sẽ đi sâu vào web server và cách cấu hình Laravel với Nginx.
x1
quay về MỤC LỤC
Cập nhật: 2026-09-22T15:27:45.173+07:00
Ở Bài 41, chúng ta đã xây dựng REST API.
Ở Bài 42, chúng ta biết cách dùng API Resource để định dạng JSON.
Ở Bài 43, chúng ta đã học Laravel Sanctum và API Token.
Bây giờ chúng ta sẽ ghép tất cả lại để xây dựng một hệ thống:
Client
│
│ email + password
▼
POST /api/login
│
▼
Laravel kiểm tra tài khoản
│
├── Sai → 422 / 401
│
└── Đúng
│
▼
Sanctum Token
│
▼
Authorization: Bearer TOKEN
│
▼
Protected API
Đây chính là nền tảng authentication thường gặp khi Laravel đóng vai trò API Backend cho:
Website JavaScript
React
Vue
Next.js
Mobile App
Desktop App
ứng dụng bên thứ ba
Laravel 13 có thể sử dụng Sanctum để xác thực API bằng token. Token được gửi trong HTTP header dưới dạng Bearer Token.
Ghi chú: mục 12 trở đi mới dùng Postman test được.
Authentication nghĩa là:
Xác định người đang gọi API là ai.
Ví dụ:
POST /api/login
Client gửi:
{
"email": "admin@example.com",
"password": "12345678"
}
Laravel kiểm tra database.
Nếu chính xác:
{
"message": "Đăng nhập thành công",
"token": "1|xxxxxxxxxxxxxxxx"
}
Client giữ token.
Những request sau đó sẽ gửi:
Authorization: Bearer 1|xxxxxxxxxxxxxxxx
Laravel sẽ biết:
Request này
↓
có token
↓
token thuộc User nào?
↓
User #5
↓
authenticated
Hai khái niệm này rất dễ nhầm.
Xác định:
Người này là ai?
Ví dụ:
User #5
email: admin@example.com
Xác định:
Người này có được phép làm việc đó không?
Ví dụ:
User #5
role = admin
được phép:
DELETE /api/posts/10
Nhưng:
User #8
role = user
không được phép.
Có thể hình dung:
Authentication
↓
"Bạn là ai?"
↓
User #5
↓
Authorization
↓
"Bạn được làm gì?"
↓
Policy / Gate / Role
Trong bài này tập trung vào Authentication.
Authorization sẽ kết hợp với Policy ở các phần nâng cao.
Nếu project Laravel 13 chưa cài API/Sanctum:
php artisan install:api
Sau đó migrate:
php artisan migrate
Lệnh install:api là cách Laravel hiện đại thiết lập API routing và Sanctum cho ứng dụng.
Trong project sẽ có:
routes/
web.php
api.php
Lưu ý:
Với Laravel 13, không nên mặc định cho rằng
routes/api.phpluôn tồn tại trong project mới. Nếu chưa có API routing, sử dụngphp artisan install:api.
Mở:
app/Models/User.php
Thêm:
use Laravel\Sanctum\HasApiTokens;
Sau đó sử dụng trait:
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
// ...
}
HasApiTokens cung cấp các chức năng cần thiết để User tạo và quản lý Sanctum token.
Tạo controller:
php artisan make:controller Api/AuthController
File:
app/Http/Controllers/Api/AuthController.php
Chúng ta sẽ xây dựng:
register()
login()
user()
logout()
logoutAll()
Đầu tiên tạo chức năng đăng ký.
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
public function register(Request $request)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'email',
'max:255',
'unique:users,email',
],
'password' => [
'required',
'string',
'min:8',
'confirmed',
],
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
]);
$token = $user->createToken('blog-api')->plainTextToken;
return response()->json([
'message' => 'Đăng ký thành công',
'user' => $user,
'token' => $token,
], 201);
}
}
Không được lưu:
'password' => $request->password
Ví dụ:
12345678
là cực kỳ nguy hiểm.
Thay vào đó:
Hash::make($validated['password'])
Database sẽ lưu password đã được hash.
Ví dụ:
$2y$12$................................
Khi login, Laravel sẽ kiểm tra password thông qua cơ chế hashing.
Bây giờ tạo login.
Thêm method:
public function login(Request $request)
{
$validated = $request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
]);
$user = User::where('email', $validated['email'])->first();
if (
!$user ||
!Hash::check(
$validated['password'],
$user->password
)
) {
throw ValidationException::withMessages([
'email' => ['Email hoặc mật khẩu không chính xác.'],
]);
}
$token = $user
->createToken('blog-api')
->plainTextToken;
return response()->json([
'message' => 'Đăng nhập thành công',
'user' => $user,
'token' => $token,
]);
}
Luồng hoạt động:
email
↓
tìm User
↓
Hash::check()
↓
password đúng?
│
├── NO → lỗi
│
└── YES
↓
createToken()
↓
plainTextToken
↓
JSON
Laravel/Sanctum cũng sử dụng mô hình kiểm tra email/password rồi tạo token cho các client như mobile app.
Sau khi login thành công:
$token = $user
->createToken('blog-api')
->plainTextToken;
Chúng ta nhận được:
1|xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Token này sẽ được client sử dụng cho những API yêu cầu authentication.
Ví dụ:
Authorization: Bearer 1|xxxxxxxxxxxxxxxx
Sanctum không lưu nguyên token dạng plain text trong database.
Token được hash trước khi lưu.
Giá trị plain-text chỉ được trả về thông qua:
$token->plainTextToken
sau khi token được tạo.
Vì vậy client phải lưu token ngay sau khi login thành công.
Mở:
routes/api.php
Thêm:
use App\Http\Controllers\Api\AuthController;
Route::post('/register', [
AuthController::class,
'register'
]);
Route::post('/login', [
AuthController::class,
'login'
]);
Khi đó:
POST /api/register
POST /api/login
là API public.
Dùng Postman.
Request:
POST
http://127.0.0.1:8000/api/register
Body:
{
"name": "Nguyen Van A",
"email": "a@example.com",
"password": "12345678",
"password_confirmation": "12345678"
}
Nếu thành công:
{
"message": "Đăng ký thành công",
"user": {
"id": 10,
"name": "Nguyen Van A",
"email": "a@example.com"
},
"token": "10|xxxxxxxxxxxxxxxx"
}
HTTP Status:
201 Created
Request:
POST
http://127.0.0.1:8000/api/login
Body:
{
"email": "a@example.com",
"password": "12345678"
}
Kết quả:
{
"message": "Đăng nhập thành công",
"user": {
"id": 10,
"name": "Nguyen Van A",
"email": "a@example.com"
},
"token": "10|xxxxxxxxxxxxxxxx"
}
Copy:
10|xxxxxxxxxxxxxxxx
để test những API private.
Bây giờ chúng ta cần API:
GET /api/user
API này trả về user đang đăng nhập.
Trong AuthController:
public function user(Request $request)
{
return response()->json([
'user' => $request->user(),
]);
}
Nhưng API này phải được bảo vệ.
Trong:
routes/api.php
viết:
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', [
AuthController::class,
'user'
]);
});
Bây giờ:
GET /api/user
không còn là API public.
Client phải gửi:
Authorization: Bearer TOKEN
Sanctum sẽ kiểm tra token và xác định User tương ứng.
Trong Postman:
GET
http://127.0.0.1:8000/api/user
Headers:
Accept: application/json
Authorization: Bearer 10|xxxxxxxxxxxxxxxx
Nếu token hợp lệ:
{
"user": {
"id": 10,
"name": "Nguyen Van A",
"email": "a@example.com"
}
}
Request:
GET /api/user
nhưng không có:
Authorization: Bearer ...
Laravel không xác định được người dùng.
API protected sẽ trả về lỗi authentication, thường là:
401 Unauthorized
Điều này có nghĩa:
Request chưa được xác thực.
Đây là phần rất quan trọng khi làm API.
Người dùng:
chưa đăng nhập
hoặc:
token không hợp lệ
Ví dụ:
GET /api/user
không có token.
Người dùng đã đăng nhập nhưng:
không có quyền thực hiện hành động
Ví dụ:
User
↓
đã authenticated
↓
DELETE /api/posts/10
↓
Policy từ chối
↓
403 Forbidden
Tóm lại:
401
↓
"Bạn là ai?"
403
↓
"Tôi biết bạn là ai,
nhưng bạn không được phép làm việc này."
Login tạo token.
Logout phải thu hồi token.
Trong AuthController:
public function logout(Request $request)
{
$request
->user()
->currentAccessToken()
->delete();
return response()->json([
'message' => 'Đăng xuất thành công',
]);
}
Route:
Route::post('/logout', [
AuthController::class,
'logout'
]);
Đặt trong:
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', [
AuthController::class,
'user'
]);
Route::post('/logout', [
AuthController::class,
'logout'
]);
});
Sanctum hỗ trợ thu hồi token hiện tại thông qua currentAccessToken()->delete().
Giả sử user đăng nhập:
Chrome
Mobile
Laptop
Tablet
mỗi thiết bị có thể có một token.
Database:
tokens
ID USER NAME
1 5 chrome
2 5 mobile
3 5 laptop
4 5 tablet
Nếu muốn đăng xuất tất cả:
public function logoutAll(Request $request)
{
$request
->user()
->tokens()
->delete();
return response()->json([
'message' => 'Đã đăng xuất khỏi tất cả thiết bị.',
]);
}
Route:
Route::post('/logout-all', [
AuthController::class,
'logoutAll'
]);
Sanctum cung cấp quan hệ tokens để quản lý các token của User.
Ở Bài 42 chúng ta đã học:
PostResource
UserResource
CategoryResource
Không nên trả toàn bộ User model một cách tùy tiện.
Ví dụ tạo:
php artisan make:resource UserResource
File:
app/Http/Resources/UserResource.php
Code:
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'role' => $this->role,
'created_at' => $this->created_at,
];
}
}
Sau đó:
use App\Http\Resources\UserResource;
Sửa:
public function user(Request $request)
{
return new UserResource(
$request->user()
);
}
Response sẽ được kiểm soát tốt hơn.
Sau khi ghép các phần lại:
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
public function register(Request $request)
{
$validated = $request->validate([
'name' => [
'required',
'string',
'max:255',
],
'email' => [
'required',
'email',
'max:255',
'unique:users,email',
],
'password' => [
'required',
'string',
'min:8',
'confirmed',
],
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make(
$validated['password']
),
]);
$token = $user
->createToken('blog-api')
->plainTextToken;
return response()->json([
'message' => 'Đăng ký thành công',
'user' => new UserResource($user),
'token' => $token,
], 201);
}
public function login(Request $request)
{
$validated = $request->validate([
'email' => [
'required',
'email',
],
'password' => [
'required',
'string',
],
]);
$user = User::where(
'email',
$validated['email']
)->first();
if (
!$user ||
!Hash::check(
$validated['password'],
$user->password
)
) {
throw ValidationException::withMessages([
'email' => [
'Email hoặc mật khẩu không chính xác.'
],
]);
}
if (!$user->is_active) {
throw ValidationException::withMessages([
'email' => [
'Tài khoản đã bị khóa.'
],
]);
}
$token = $user
->createToken('blog-api')
->plainTextToken;
return response()->json([
'message' => 'Đăng nhập thành công',
'user' => new UserResource($user),
'token' => $token,
]);
}
public function user(Request $request)
{
return new UserResource(
$request->user()
);
}
public function logout(Request $request)
{
$request
->user()
->currentAccessToken()
->delete();
return response()->json([
'message' => 'Đăng xuất thành công.',
]);
}
public function logoutAll(Request $request)
{
$request
->user()
->tokens()
->delete();
return response()->json([
'message' => 'Đã đăng xuất khỏi tất cả thiết bị.',
]);
}
}
Ở đây có một điểm rất thực tế đối với Blog CMS của chúng ta:
if (!$user->is_active)
Nếu admin đã khóa tài khoản:
is_active = false
thì user đó không thể login API.
File:
routes/api.php
<?php
use App\Http\Controllers\Api\AuthController;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| Public API
|--------------------------------------------------------------------------
*/
Route::post('/register', [
AuthController::class,
'register'
]);
Route::post('/login', [
AuthController::class,
'login'
]);
/*
|--------------------------------------------------------------------------
| Protected API
|--------------------------------------------------------------------------
*/
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', [
AuthController::class,
'user'
]);
Route::post('/logout', [
AuthController::class,
'logout'
]);
Route::post('/logout-all', [
AuthController::class,
'logoutAll'
]);
});
Sau khi hoàn thành:
| Method | URL | Authentication |
|---|---|---|
| POST | /api/register | Không |
| POST | /api/login | Không |
| GET | /api/user | Có |
| POST | /api/logout | Có |
| POST | /api/logout-all | Có |
Luồng:
REGISTER
↓
LOGIN
↓
TOKEN
↓
Bearer Token
↓
GET /api/user
↓
POST /api/logout
Bây giờ chúng ta có thể kết hợp authentication với Blog CMS.
Ví dụ:
GET /api/posts
cho phép public.
Nhưng:
POST /api/posts
PUT /api/posts/{post}
DELETE /api/posts/{post}
yêu cầu login.
Có thể viết:
Route::get('/posts', [
PostController::class,
'index'
]);
Route::middleware('auth:sanctum')->group(function () {
Route::post('/posts', [
PostController::class,
'store'
]);
Route::put('/posts/{post}', [
PostController::class,
'update'
]);
Route::delete('/posts/{post}', [
PostController::class,
'destroy'
]);
});
Khi đó:
GET /api/posts
↓
PUBLIC
POST /api/posts
↓
AUTHENTICATION
↓
Sanctum
DELETE /api/posts/10
↓
AUTHENTICATION
↓
AUTHORIZATION
↓
Policy
Đây chính là kiến trúc API thực tế.
Đây là điểm cần nhớ.
Có token:
Bearer xxxxx
chỉ chứng minh:
User đã authenticated
Không có nghĩa:
User được phép làm mọi thứ.
Ví dụ:
User #10
role = user
có thể:
POST /api/posts
nhưng không nhất thiết được:
DELETE /api/users/5
Để kiểm tra quyền:
Authentication
↓
Sanctum
↓
User
↓
Authorization
↓
Policy / Gate / Role
Đây là lý do các bài:
Bài 43 — Sanctum
Bài 44 — API Authentication
sẽ tiếp tục kết nối rất tốt với kiến thức:
Bài 25 — Authorization
Sanctum còn hỗ trợ token abilities.
Ví dụ:
$token = $user->createToken(
'mobile',
['posts:read']
)->plainTextToken;
Token này có ability:
posts:read
Sau đó có thể kiểm tra:
$request->user()->tokenCan('posts:read')
Ví dụ:
if (!$request->user()->tokenCan('posts:read')) {
abort(403);
}
Sanctum cho phép gắn abilities/scopes vào token để giới hạn những hành động token có thể thực hiện.
Giả sử có:
Website
Mobile App
Admin App
Một User có thể có:
Token 1 → website
Token 2 → mobile
Token 3 → admin
Mỗi token có thể có mục đích khác nhau.
Ví dụ:
mobile
↓
posts:read
admin
↓
posts:read
posts:create
posts:update
posts:delete
Như vậy hệ thống có thể kiểm soát API ở mức chi tiết hơn.
POST /api/register
Body:
{
"name": "Admin",
"email": "admin@example.com",
"password": "12345678",
"password_confirmation": "12345678"
}
POST /api/login
Body:
{
"email": "admin@example.com",
"password": "12345678"
}
Copy:
token
GET /api/user
Header:
Authorization: Bearer TOKEN
POST /api/logout
Header:
Authorization: Bearer TOKEN
Sau khi logout:
TOKEN
↓
REVOKED
Token đó không còn dùng được nữa.
Sai:
Authorization: TOKEN
Đúng:
Authorization: Bearer TOKEN
Ví dụ:
Authorization: Bearer 1|abc123xyz
Từ khóa:
Bearer
rất quan trọng.
Sanctum API token được truyền qua Authorization header dưới dạng Bearer token.
Không nên:
/api/posts?token=xxxxx
hoặc:
/api/user/xxxxx
Thay vào đó:
Authorization: Bearer TOKEN
Token là thông tin xác thực, vì vậy phải hạn chế tối đa việc để nó xuất hiện trong URL, log hoặc nơi có thể bị lưu lại ngoài ý muốn.
Không viết:
$token = '1|abcdef123456';
vào source code.
Không commit token thật vào:
GitHub
GitLab
Bitbucket
Token phải được quản lý như credential.
Nếu token bị lộ:
REVOKE
và tạo token mới.
Trong Blog CMS hiện tại của chúng ta có:
Breeze
Breeze xử lý:
Web Login
Web Register
Session
Cookie
Trong khi API Authentication sử dụng:
Sanctum
Token
Bearer
Có thể hình dung:
Laravel 13
│
┌──────────┴──────────┐
│ │
WEB APP API
│ │
Breeze Sanctum
│ │
Session Token
│ │
Browser Mobile / SPA
Hai hệ thống có thể cùng tồn tại trong một project.
Kiến trúc hiện tại:
Laravel 13 Blog CMS
│
┌──────────────┴──────────────┐
│ │
WEB API
│ │
Breeze Sanctum
│ │
Session/Cookie Token
│ │
Browser Mobile / SPA
│
▼
Authorization Header
│
Bearer Token
│
▼
auth:sanctum
│
▼
User
│
┌─────────┴─────────┐
│ │
API Resource Policy
│ │
▼ ▼
JSON Permission
Đây là mô hình rất gần với một Laravel API Backend thực tế.
Chạy:
php artisan route:list --path=api -v
Bạn sẽ thấy nhóm API tương tự:
POST api/register
POST api/login
GET api/user
POST api/logout
POST api/logout-all
Các route protected sẽ có:
auth:sanctum
Tạo:
POST /api/register
cho phép User đăng ký.
Tạo:
POST /api/login
và trả về:
{
"message": "...",
"user": {},
"token": "..."
}
Tạo:
GET /api/user
yêu cầu:
auth:sanctum
Tạo:
POST /api/logout
để thu hồi token hiện tại.
Tạo:
POST /api/logout-all
để thu hồi toàn bộ token của User.
Thử gọi:
GET /api/user
không có token.
Quan sát:
401
Sau đó đăng nhập và thử lại với:
Authorization: Bearer TOKEN
Xác định User là ai.
Xác định User được làm gì.
API Token Authentication
createToken()
$token->plainTextToken
auth:sanctum
$request->user()
$request
->user()
->currentAccessToken()
->delete();
$request
->user()
->tokens()
->delete();
Authorization: Bearer TOKEN
Sau bài này, chúng ta đã hoàn thành phần:
REST API
↓
API Resource
↓
Sanctum
↓
API Authentication
Và Blog CMS hiện tại đã có thể xây dựng luồng:
REGISTER
↓
LOGIN
↓
SANCTUM TOKEN
↓
BEARER TOKEN
↓
AUTHENTICATED USER
↓
PROTECTED API
↓
LOGOUT
↓
REVOKE TOKEN
Quan trọng hơn, chúng ta đã phân biệt được:
Authentication
≠
Authorization
Authentication trả lời:
Bạn là ai?
Authorization trả lời:
Bạn được phép làm gì?
Đây là hai khái niệm nền tảng khi xây dựng API thực tế.
Phần REST API của khóa học đã hoàn thành:
Bài 41 — REST API
↓
Bài 42 — API Resource
↓
Bài 43 — Sanctum
↓
Bài 44 — API Authentication
Tiếp theo chúng ta chuyển sang:
Chúng ta sẽ đưa Blog CMS từ:
localhost
lên:
Shared Hosting
và bắt đầu học những vấn đề rất thực tế như:
Upload source
Database
.env
APP_KEY
Storage
public/
Document Root
Migration
Vite build
Permission
Đây là bước chuyển từ:
"Laravel chạy được trên máy mình"
sang:
x1"Laravel chạy được trên server thật."
Cập nhật: 2026-09-22T15:27:10.371+07:00
Laravel Sanctum cung cấp cơ chế xác thực nhẹ cho API token, SPA và các ứng dụng mobile.
Ở Bài 41, chúng ta đã xây dựng REST API.
Ở Bài 42, chúng ta dùng API Resource để kiểm soát dữ liệu JSON.
Nhưng API hiện tại có một vấn đề:
GET /api/posts
POST /api/posts
PUT /api/posts/1
DELETE /api/posts/1
Ai cũng có thể gửi request.
Ví dụ:
Anonymous User
↓
POST /api/posts
↓
Tạo bài viết?
Đây rõ ràng không phải điều chúng ta muốn.
Chúng ta cần biết:
Ai đang gọi API?
Và:
Người đó có được phép gọi API này không?
Đó là lúc Laravel Sanctum xuất hiện.
Sanctum là cơ chế xác thực nhẹ được Laravel cung cấp cho:
API token
SPA
Mobile application
Các API đơn giản cần token authentication
Sanctum cho phép user tạo nhiều personal access token và dùng token đó để xác thực các request API.
Có thể hình dung:
Client
│
│ Authorization: Bearer TOKEN
↓
Laravel API
│
↓
Sanctum
│
├── Token hợp lệ?
│
└── User nào?
↓
Controller
Nếu token hợp lệ:
Request
↓
Authenticated User
↓
Controller
Nếu không hợp lệ:
Request
↓
401 Unauthorized
Đây là điểm rất quan trọng.
Authentication:
Bạn là ai?
Authorization:
Bạn có quyền làm gì?
Ví dụ:
Authentication
↓
User ID = 10
Sau đó:
Authorization
↓
User 10 có được xóa Post 5 không?
Sanctum chủ yếu giải quyết vấn đề:
Authentication
Còn:
Authorization
Policies
Gates
Roles
Permissions
là những phần khác của Laravel.
Ví dụ user đăng nhập và nhận token:
User
↓
Login
↓
Sanctum
↓
API Token
Ví dụ token:
1|abc123xyz...
Client lưu token.
Sau đó gửi:
Authorization: Bearer 1|abc123xyz...
Laravel nhận request:
Request
↓
Sanctum
↓
Kiểm tra token
↓
User
Nếu hợp lệ:
$request->user()
sẽ trả về user đang đăng nhập.
Với Laravel hiện đại, cách đơn giản để cài API routing và Sanctum là:
php artisan install:api
Lệnh này được Laravel sử dụng để thiết lập API routing và Sanctum.
Chạy:
php artisan install:api
Sau đó chạy migration:
php artisan migrate
Sau khi cài đặt, kiểm tra:
routes/
├── web.php
├── api.php
└── console.php
API route:
routes/api.php
Sanctum sử dụng database để quản lý personal access tokens.
Trong database sẽ có bảng:
personal_access_tokens
Bảng này được Sanctum dùng để lưu thông tin token đã được cấp.
Mở:
app/Models/User.php
Thêm:
use Laravel\Sanctum\HasApiTokens;
Sau đó:
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
// ...
}
Trong project thực tế, User Model của chúng ta có thể đang sử dụng nhiều trait khác nhau.
Chỉ cần bổ sung:
HasApiTokens
vào danh sách trait.
Sanctum cung cấp trait này để User có thể tạo và quản lý API tokens.
Sanctum cung cấp:
createToken()
Ví dụ:
$token = $user->createToken('blog-api');
Token được tạo ra có thể lấy bằng:
$token->plainTextToken
Ví dụ:
return [
'token' => $token->plainTextToken
];
Sanctum chỉ cung cấp giá trị plaintext của token ngay lúc token được tạo; token được lưu dưới dạng hash trong database. Vì vậy giá trị plaintext phải được bảo vệ và gửi cho client tại thời điểm cấp token.
Để học Sanctum, chúng ta có thể tạo một route demo.
routes/api.php:
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::post('/tokens/create', function (Request $request) {
$token = $request->user()->createToken(
$request->token_name
);
return [
'token' => $token->plainTextToken
];
});
Nhưng có một vấn đề:
$request->user()
cần có authenticated user.
Do đó trong project thật, chúng ta cần thiết kế endpoint cấp token dựa trên thông tin đăng nhập của user.
Phần login API hoàn chỉnh sẽ được xử lý ở Bài 44 — API Authentication.
Ở bài này, chúng ta tập trung hiểu cơ chế Sanctum.
Token có thể hiểu đơn giản là:
Một chuỗi bí mật đại diện cho quyền truy cập API của một user.
Ví dụ:
1|K7x9fL2mQ...
Client không cần gửi:
email
password
ở mọi request.
Thay vào đó:
Authorization: Bearer 1|K7x9fL2mQ...
Laravel dùng token để xác định user.
Đây là format phổ biến:
Authorization: Bearer TOKEN
Ví dụ:
Authorization: Bearer 1|K7x9fL2mQ...
Có thể hình dung:
Authorization
│
├── Bearer
│
└── Token
Bearer cho biết request đang sử dụng một bearer token để xác thực.
auth:sanctumĐây là phần quan trọng nhất.
Ví dụ API:
Route::get('/user', function (Request $request) {
return $request->user();
})->middleware('auth:sanctum');
Middleware:
auth:sanctum
sẽ yêu cầu request phải được Sanctum xác thực. Laravel tài liệu hướng dẫn sử dụng middleware này để bảo vệ API routes.
Client gọi:
GET /api/user
nhưng không gửi:
Authorization: Bearer ...
Laravel sẽ không xác thực được user.
Kết quả thường là:
401 Unauthorized
Có thể hình dung:
GET /api/user
↓
auth:sanctum
↓
Token?
NO ↓
401
Client gửi:
GET /api/user
Authorization: Bearer TOKEN
Sanctum kiểm tra:
Token
↓
personal_access_tokens
↓
Hợp lệ?
↓
User
Sau đó:
$request->user()
sẽ trả về user đã được xác thực.
/api/userTrong:
routes/api.php
có thể viết:
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::get('/user', function (Request $request) {
return $request->user();
})->middleware('auth:sanctum');
Request:
GET /api/user
Nếu token hợp lệ:
{
"id": 1,
"name": "Admin",
"email": "admin@example.com"
}
Ở Bài 42 chúng ta đã học API Resource.
Do đó thay vì:
return $request->user();
chúng ta có thể sử dụng:
return new UserResource(
$request->user()
);
Ví dụ:
use App\Http\Resources\UserResource;
use Illuminate\Http\Request;
Route::get('/user', function (Request $request) {
return new UserResource(
$request->user()
);
})->middleware('auth:sanctum');
Luồng:
Sanctum
↓
Authenticated User
↓
UserResource
↓
JSON
Đây chính là sự kết hợp giữa:
Bài 42
API Resource
+
Bài 43
Sanctum
Ở Bài 41 chúng ta có:
Route::apiResource('posts', PostController::class);
Nhưng nếu toàn bộ route đều public thì:
POST
PUT
DELETE
cũng có thể bị gọi bởi người chưa đăng nhập.
Chúng ta có thể tách route.
Ví dụ:
Route::get('/posts', [PostController::class, 'index']);
Route::get('/posts/{post}', [PostController::class, 'show']);
Route::middleware('auth:sanctum')->group(function () {
Route::post('/posts', [PostController::class, 'store']);
Route::put('/posts/{post}', [PostController::class, 'update']);
Route::patch('/posts/{post}', [PostController::class, 'update']);
Route::delete('/posts/{post}', [PostController::class, 'destroy']);
});
Khi đó:
GET /api/posts
có thể public.
Nhưng:
POST /api/posts
PUT /api/posts/1
DELETE /api/posts/1
cần authentication.
apiResourceMột cách khác:
Route::apiResource('posts', PostController::class)
->only(['index', 'show']);
Route::apiResource('posts', PostController::class)
->except(['index', 'show'])
->middleware('auth:sanctum');
Tuy nhiên với người mới học, cách chia route thành hai nhóm thường dễ nhìn hơn:
Route::get(...);
Route::get(...);
Route::middleware('auth:sanctum')->group(function () {
// private API
});
Chúng ta có thể phân chia:
PUBLIC API
GET /api/posts
GET /api/posts/1
GET /api/categories
và:
PRIVATE API
POST /api/posts
PUT /api/posts/1
DELETE /api/posts/1
Private API yêu cầu:
Authentication
Có thể hình dung:
API
│
┌─────────┴─────────┐
│ │
PUBLIC PRIVATE
│ │
GET POST
GET PUT
DELETE
│
auth:sanctum
Một lỗi phổ biến là nhầm:
password
với:
API token
Password dùng để xác thực user trong quá trình đăng nhập.
Token được cấp sau đó có thể được client dùng cho các request API.
Ví dụ:
Email + Password
↓
Authentication
↓
Sanctum Token
↓
API Requests
Không nên đưa password vào mỗi API request.
Khi tạo token:
$user->createToken('blog-api');
Tên:
blog-api
chỉ là tên giúp nhận biết token.
Ví dụ user có:
blog-web
iphone
android
desktop
Các token này có thể thuộc cùng một user.
Sanctum hỗ trợ nhiều token trên một user và cho phép quản lý chúng qua relationship tokens.
User Model sử dụng:
HasApiTokens
thì có thể truy cập:
$user->tokens
Ví dụ:
foreach ($user->tokens as $token) {
// ...
}
Có thể hình dung:
User
│
├── Token 1
├── Token 2
└── Token 3
Điều này rất hữu ích khi ứng dụng cho phép user đăng nhập từ nhiều thiết bị.
Token có thể bị revoke bằng cách xóa token khỏi database.
Ví dụ token hiện tại:
$request->user()
->currentAccessToken()
->delete();
Điều này có thể dùng cho:
POST /api/logout
Ví dụ:
Route::post('/logout', function (Request $request) {
$request->user()
->currentAccessToken()
->delete();
return response()->json([
'message' => 'Logged out successfully'
]);
})->middleware('auth:sanctum');
Sau khi token bị xóa:
Token cũ
↓
Không còn hợp lệ
Sanctum hỗ trợ revoke token thông qua relationship tokens hoặc currentAccessToken().
Ví dụ user chọn:
Đăng xuất khỏi tất cả thiết bị.
Có thể:
$request->user()
->tokens()
->delete();
Khi đó:
Token Web
Token Android
Token iPhone
Token Desktop
đều bị revoke.
Sanctum còn hỗ trợ abilities cho token.
Ví dụ tạo token:
$token = $user->createToken(
'mobile',
['posts:read']
);
Token này có ability:
posts:read
Có thể tạo token khác:
$user->createToken(
'admin',
[
'posts:read',
'posts:create',
'posts:update',
'posts:delete'
]
);
Khi đó có thể phân biệt:
Read-only token
và:
Full-access token
Trong request:
if ($request->user()->tokenCan('posts:read')) {
// ...
}
Ví dụ:
if (! $request->user()->tokenCan('posts:delete')) {
abort(403);
}
Luồng:
User
↓
Token
↓
Abilities
↓
posts:delete?
↓
YES / NO
Đây là một bước tiến từ:
Authentication
sang:
Authorization
Tuy nhiên project Blog CMS của chúng ta đã có:
role
Policy
nên trong dự án thực tế cần thiết kế hai tầng này một cách rõ ràng, thay vì nhồi mọi permission vào token.
Theo mặc định, Sanctum token không tự hết hạn và có thể bị vô hiệu hóa bằng cách revoke token. Sanctum có tùy chọn cấu hình thời gian hết hạn nếu ứng dụng cần.
Ví dụ ý tưởng:
Token
↓
Expiration
↓
Hết hạn
↓
Không sử dụng được
Đây là một điểm cần quan tâm khi xây dựng API production.
Sanctum không chỉ có API token.
Sanctum còn hỗ trợ authentication cho first-party SPA bằng cookie/session.
Đây là điểm rất quan trọng:
API Token
và:
SPA Authentication
là hai cơ chế khác nhau trong Sanctum.
Với SPA của chính bạn, Sanctum có thể sử dụng session cookie thay vì personal access token.&
Tài liệu Laravel cũng lưu ý không nên dùng API token như cơ chế xác thực cho first-party SPA nếu SPA authentication của Sanctum phù hợp hơn.
Mobile App
↓
Bearer Token
↓
Laravel
↓
Sanctum
Phù hợp với:
Mobile
Third-party API
Personal API token
React / Vue SPA
↓
Session Cookie
↓
Laravel
↓
Sanctum
Phù hợp với:
First-party SPA
Không nên học hai cơ chế này như thể chúng là cùng một thứ.
statefulApi()Nếu sử dụng Sanctum cho first-party SPA, Laravel có middleware hỗ trợ stateful API.
Trong bootstrap/app.php có thể cấu hình:
->withMiddleware(function (Middleware $middleware): void {
$middleware->statefulApi();
})
Cơ chế này cho phép các request từ SPA của bạn được xác thực bằng session cookie, trong khi Sanctum vẫn có thể xác thực API token cho các request thích hợp.
Phần này chúng ta chỉ cần biết ở Bài 43.
Không cần triển khai SPA authentication đầy đủ ở đây.
Project của chúng ta hiện có:
users
categories
posts
và:
role
is_active
API có thể thiết kế:
PUBLIC
GET /api/posts
GET /api/posts/1
GET /api/categories
PRIVATE:
POST /api/posts
PUT /api/posts/1
DELETE /api/posts/1
Sau Sanctum:
Client
↓
Bearer Token
↓
Sanctum
↓
User
↓
Controller
↓
Policy
↓
Post
Đây mới là kiến trúc hợp lý.
Ví dụ:
User #10
đã đăng nhập thành công.
Sanctum xác nhận:
User #10 là người đang gọi API.
Nhưng User #10 có được sửa Post #100 hay không?
Đó là câu hỏi khác.
Ta cần:
Policy
Ví dụ:
Sanctum
↓
Authentication
↓
User #10
↓
PostPolicy
↓
Được sửa?
Vì vậy:
Sanctum ≠ Permission
auth:sanctum + PolicyVí dụ:
Route::put('/posts/{post}', [
PostController::class,
'update'
])
->middleware('auth:sanctum');
Controller:
public function update(Request $request, Post $post)
{
$this->authorize('update', $post);
// ...
}
Luồng:
Request
↓
auth:sanctum
↓
User authenticated?
↓
YES
↓
Policy
↓
Có quyền update?
↓
YES
↓
Update Post
Đây là kiến trúc authentication + authorization rất quan trọng.
Sau khi có token:
TOKEN = 1|abc123...
Trong Postman:
Authorization
chọn:
Bearer Token
nhập:
1|abc123...
Postman sẽ gửi:
Authorization: Bearer 1|abc123...
Sau đó gọi:
GET /api/user
Nếu token hợp lệ:
200 OK
Nếu token không hợp lệ:
401 Unauthorized
Không nên:
/api/posts?token=abc123
Không nên:
/api/user/abc123
Thay vào đó:
Authorization: Bearer TOKEN
Token là thông tin nhạy cảm.
Không nên đưa token vào:
URL
HTML
log
screenshot
Git repository
Ví dụ tuyệt đối không viết:
$token = '1|abc123...';
rồi commit lên Git.
Không nên lưu token thật trong:
source code
hoặc:
README
hoặc:
.env.example
Token thật phải được bảo vệ.
Nếu token bị lộ:
Revoke token
↓
Tạo token mới
routes/api.php:
<?php
use App\Http\Controllers\Api\PostController;
use App\Http\Resources\UserResource;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::get('/user', function (Request $request) {
return new UserResource($request->user());
})->middleware('auth:sanctum');
Route::get('/posts', [
PostController::class,
'index'
]);
Route::get('/posts/{post}', [
PostController::class,
'show'
]);
Route::middleware('auth:sanctum')->group(function () {
Route::post('/posts', [
PostController::class,
'store'
]);
Route::put('/posts/{post}', [
PostController::class,
'update'
]);
Route::patch('/posts/{post}', [
PostController::class,
'update'
]);
Route::delete('/posts/{post}', [
PostController::class,
'destroy'
]);
Route::post('/logout', function (Request $request) {
$request->user()
->currentAccessToken()
->delete();
return response()->json([
'message' => 'Logged out successfully'
]);
});
});
Kiến trúc:
PUBLIC
│
├── GET /api/posts
└── GET /api/posts/{post}
PRIVATE
│
├── GET /api/user
├── POST /api/posts
├── PUT /api/posts/{post}
├── PATCH /api/posts/{post}
├── DELETE /api/posts/{post}
└── POST /api/logout
Chạy:
php artisan route:list --path=api
Có thể thấy:
GET|HEAD api/posts
POST api/posts
GET|HEAD api/posts/{post}
PUT api/posts/{post}
PATCH api/posts/{post}
DELETE api/posts/{post}
GET|HEAD api/user
POST api/logout
Các route private sẽ có middleware:
auth:sanctum
Hiện tại chúng ta có thể hình dung:
USER
│
▼
Login API
│
▼
Sanctum Token
│
▼
┌─────────────────┐
│ API Client │
└─────────────────┘
│
│ Bearer Token
▼
Laravel API
│
▼
auth:sanctum
│
┌────────┴────────┐
│ │
Invalid Valid
│ │
▼ ▼
401 User
│
▼
Policy
│
▼
Controller
│
▼
Model
│
▼
Resource
│
▼
JSON
Đây là kiến trúc mà chúng ta sẽ hoàn thiện ở Bài 44.
Chạy:
php artisan install:api
Sau đó:
php artisan migrate
Thêm:
use Laravel\Sanctum\HasApiTokens;
và:
use HasApiTokens, HasFactory, Notifiable;
Tạo:
GET /api/user
với:
->middleware('auth:sanctum')
Gọi:
GET /api/user
Không gửi Authorization.
Quan sát:
401 Unauthorized
Gửi:
Authorization: Bearer YOUR_TOKEN
Sau đó gọi:
GET /api/user
Quan sát user trả về.
Tạo:
POST /api/logout
Sau khi logout:
Token cũ
↓
Không còn hợp lệ
Public:
GET /api/posts
GET /api/posts/1
Private:
POST /api/posts
PUT /api/posts/1
DELETE /api/posts/1
API Authentication
SPA Authentication
Mobile Authentication
API Tokens
use Laravel\Sanctum\HasApiTokens;
$user->createToken('blog-api');
$token->plainTextToken
Authorization: Bearer TOKEN
->middleware('auth:sanctum')
$request->user()
$request->user()->currentAccessToken()
$request->user()
->currentAccessToken()
->delete();
$request->user()
->tokens()
->delete();
Hãy nhớ thật kỹ:
SANCTUM
↓
Authentication
↓
"Bạn là ai?"
Còn:
POLICY / GATE / PERMISSION
↓
Authorization
↓
"Bạn được phép làm gì?"
Ví dụ:
User đăng nhập
↓
Sanctum
↓
User #5
↓
Policy
↓
User #5 có được xóa Post #10?
Hai vấn đề này phải được xử lý riêng.
Sau Bài 41:
REST API
Sau Bài 42:
API Resource
Bây giờ Bài 43:
Sanctum
Chúng ta đã xây dựng được:
Laravel 13 API
│
┌───────┴───────┐
│ │
Public Private
│ │
GET API auth:sanctum
│
Token
│
User
API bây giờ không còn là:
Ai cũng gọi được
mà đã có:
Client
↓
Bearer Token
↓
Sanctum
↓
Authenticated User
Tuy nhiên chúng ta vẫn còn một mảnh ghép rất quan trọng:
User lấy token bằng cách nào?
Chúng ta cần một API:
POST /api/login
Client gửi:
{
"email": "admin@example.com",
"password": "password"
}
Laravel kiểm tra:
Email
+
Password
Nếu chính xác:
↓
Sanctum
↓
API Token
↓
Client
Sau đó client dùng token để gọi:
GET /api/user
GET /api/posts
POST /api/posts
PUT /api/posts/1
DELETE /api/posts/1
Đó chính là nội dung tiếp theo:
Chúng ta sẽ hoàn thiện:
Register API
Login API
Logout API
Bearer Token
auth:sanctum
User API
Validation
401 Unauthorized
403 Forbidden
Token revoke
API Resource
và ghép tất cả thành một API Authentication hoàn chỉnh cho Blog CMS Laravel 13.
x1
quay về MỤC LỤC
Cập nhật: 2026-09-19T23:40:09.721+07:00
API Resource là lớp trung gian giúp chúng ta kiểm soát dữ liệu từ Eloquent Model trước khi Laravel trả dữ liệu về cho API client dưới dạng JSON.
Ở Bài 41 — REST API, chúng ta đã tạo được:
GET /api/posts
GET /api/posts/{post}
POST /api/posts
PUT /api/posts/{post}
DELETE /api/posts/{post}
Nhưng chúng ta gặp một vấn đề:
return $post;
hoặc:
return Post::all();
Laravel có thể tự chuyển Model thành JSON.
Điều này rất tiện.
Nhưng đối với một API thực tế, chúng ta thường không muốn trả toàn bộ dữ liệu của Model.
Đó chính là lúc:
xuất hiện.
Có thể hình dung:
Database
↓
Eloquent Model
↓
API Resource
↓
JSON
↓
Frontend / Mobile App
Ví dụ Database có:
posts
id
user_id
category_id
title
slug
excerpt
content
image
status
published_at
view_count
created_at
updated_at
Nhưng API public có thể chỉ cần:
{
"id": 1,
"title": "Laravel 13 là gì?",
"slug": "laravel-13-la-gi",
"excerpt": "Tìm hiểu Laravel 13",
"image": "...",
"published_at": "...",
"view_count": 125
}
Chúng ta không nhất thiết phải đưa toàn bộ database record ra ngoài.
API Resource tạo ra một lớp biến đổi dữ liệu nằm giữa Model và JSON response. Đây cũng là mục đích chính của Resource trong Laravel.
Ở bài trước chúng ta có thể viết:
public function show(Post $post)
{
return $post;
}
Cách này rất nhanh.
Nhưng hãy tưởng tượng Model Post có thêm:
internal_flag
admin_note
some_internal_data
...
Nếu trả thẳng Model:
return $post;
chúng ta có thể vô tình đưa những dữ liệu không cần thiết ra API.
Ngoài ra frontend có thể cần cấu trúc:
{
"id": 1,
"title": "...",
"author": {
"id": 1,
"name": "Admin"
},
"category": {
"id": 2,
"name": "Laravel"
}
}
Trong khi Model lại có:
user_id
category_id
Resource cho phép chúng ta quyết định chính xác:
API sẽ trả về dữ liệu gì và dữ liệu được tổ chức như thế nào.
Laravel đặt Resource mặc định trong:
app/Http/Resources
Ví dụ:
app/
└── Http/
└── Resources/
└── PostResource.php
Resource thông thường kế thừa:
Illuminate\Http\Resources\Json\JsonResource
Chạy:
php artisan make:resource PostResource
Laravel tạo:
app/Http/Resources/PostResource.php
File ban đầu có dạng:
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PostResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
return [
//
];
}
}
Đây chính là nơi chúng ta định nghĩa dữ liệu API.
toArray() là gì?Phương thức quan trọng nhất của Resource là:
toArray()
Ví dụ:
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
];
}
Nếu Post có:
id = 1
title = Laravel 13
slug = laravel-13
API sẽ trả:
{
"data": {
"id": 1,
"title": "Laravel 13",
"slug": "laravel-13"
}
}
Resource đã biến Model thành một cấu trúc JSON mà chúng ta kiểm soát được.
$this trong Resource là gì?Đây là điểm người mới học rất dễ nhầm.
Trong:
class PostResource extends JsonResource
$this đại diện cho Resource đang bao bọc Model.
Ví dụ:
return [
'id' => $this->id,
'title' => $this->title,
];
thì:
$this->id
chính là:
$post->id
và:
$this->title
chính là:
$post->title
Có thể hình dung:
Post Model
↓
PostResource
↓
$this
↓
Dữ liệu của Post
Với cấu trúc posts hiện tại, chúng ta có thể viết:
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PostResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'excerpt' => $this->excerpt,
'image' => $this->image,
'status' => $this->status,
'published_at' => $this->published_at,
'view_count' => $this->view_count,
];
}
}
Bây giờ API không còn phụ thuộc vào việc Laravel tự động serialize toàn bộ Model.
Chúng ta đã định nghĩa rõ:
API trả:
id
title
slug
excerpt
image
status
published_at
view_count
Trước đây:
public function show(Post $post)
{
return $post;
}
Bây giờ:
use App\Http\Resources\PostResource;
public function show(Post $post)
{
return new PostResource($post);
}
Kết quả:
{
"data": {
"id": 1,
"title": "Laravel 13",
"slug": "laravel-13",
"excerpt": "..."
}
}
Resource mặc định có lớp data ở ngoài khi trả một resource.&
Cách wrapping này là một phần của hệ thống API Resource của Laravel.
Ghi chú: đến mục 8 này ví dụ mới chạy được.
Một Post:
new PostResource($post)
Nhưng danh sách nhiều Post thì sao?
Chúng ta có thể sử dụng:
PostResource::collection($posts)
Ví dụ:
public function index()
{
$posts = Post::published()
->latest('published_at')
->get();
return PostResource::collection($posts);
}
Kết quả:
{
"data": [
{
"id": 1,
"title": "Laravel 13"
},
{
"id": 2,
"title": "REST API"
}
]
}
Đây là cách rất phổ biến khi trả danh sách Model thông qua Resource.
PostResource::collection()Không cần phải tạo một Controller riêng cho collection.
Chỉ cần:
PostResource::collection($posts)
Laravel sẽ áp dụng:
PostResource
cho từng Post.
Ví dụ:
posts
├── Post #1
├── Post #2
└── Post #3
↓
PostResource
↓
PostResource
↓
PostResource
↓
JSON
Đây là trường hợp rất quan trọng đối với Blog CMS.
Ở Bài 41 chúng ta đã có:
$posts = Post::published()
->latest('published_at')
->paginate(10);
Bây giờ có thể:
return PostResource::collection($posts);
Laravel sẽ xử lý collection phân trang và response vẫn có thông tin pagination.
Ví dụ cấu trúc có thể gồm:
{
"data": [
{
"id": 1,
"title": "Laravel 13"
}
],
"links": {
"first": "...",
"last": "...",
"prev": null,
"next": "..."
},
"meta": {
"current_page": 1,
"from": 1,
"last_page": 5,
"per_page": 10,
"to": 10,
"total": 50
}
}
Laravel paginator vốn đã hỗ trợ chuyển kết quả phân trang thành JSON và cung cấp thông tin meta, links và data.
Ghi chú:&
Thông tin paging trong laravel 13 phải dd($posts) mới thấy???
Blog CMS của chúng ta có:
User
│
└── Posts
Category
│
└── Posts
Post
├── User
└── Category
Trong Post Model:
public function user()
{
return $this->belongsTo(User::class);
}
public function category()
{
return $this->belongsTo(Category::class);
}
API có thể muốn trả:
{
"id": 1,
"title": "Laravel 13",
"author": {
"id": 1,
"name": "Admin"
},
"category": {
"id": 2,
"name": "Laravel"
}
}
Resource rất phù hợp để xử lý trường hợp này.
Chạy:
php artisan make:resource UserResource
File:
app/Http/Resources/UserResource.php
Viết:
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
];
}
}
Chạy:
php artisan make:resource CategoryResource
Viết:
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class CategoryResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
];
}
}
Bây giờ chúng ta có:
app/Http/Resources/
PostResource.php
UserResource.php
CategoryResource.php
PostResource.php:
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PostResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'excerpt' => $this->excerpt,
'image' => $this->image,
'published_at' => $this->published_at,
'view_count' => $this->view_count,
'author' => new UserResource($this->whenLoaded('user')),
'category' => new CategoryResource(
$this->whenLoaded('category')
),
];
}
}
Đây là một kỹ thuật rất quan trọng:
$this->whenLoaded('user')
và:
$this->whenLoaded('category')
whenLoaded() là gì?Giả sử Controller:
$posts = Post::with(['user', 'category'])->get();
thì:
user
category
đã được load.
Resource có thể đưa chúng vào JSON.
Nhưng nếu Controller không load relationship:
$posts = Post::all();
thì Resource không nhất thiết phải truy vấn relationship một cách vô điều kiện.
Đó là lý do:
$this->whenLoaded('user')
rất hữu ích.
Có thể hiểu:
Relationship đã load?
│
┌───┴───┐
│ │
YES NO
│ │
trả về bỏ qua
Đây là cách chúng ta nên viết:
public function index()
{
$posts = Post::published()
->with(['user', 'category'])
->latest('published_at')
->paginate(10);
return PostResource::collection($posts);
}
Luồng:
Database
↓
Post
↓
with(user, category)
↓
PostResource
↓
JSON
Resource chịu trách nhiệm định dạng.
Eloquent chịu trách nhiệm lấy dữ liệu.
Controller chịu trách nhiệm điều phối.
Đây là một nguyên tắc rất quan trọng.
Quan hệ
Scope
Database logic
Nhận request
Gọi Model
Gọi Resource
Định dạng dữ liệu API
Lưu trữ dữ liệu
Có thể hình dung:
Request
↓
Controller
↓
Model
↓
Database
↑
Model
↓
Resource
↓
JSON Response
Đừng nhầm:
Post
với:
PostResource
Post:
use App\Models\Post;
là Eloquent Model.
Nó đại diện cho dữ liệu và logic liên quan đến bảng:
posts
Trong khi:
use App\Http\Resources\PostResource;
là lớp dùng để biến Post thành API response.
Post
↓
PostResource
↓
JSON
Ví dụ Controller:
public function show(Post $post)
{
$post->load(['user', 'category']);
return new PostResource($post);
}
API:
GET /api/posts/1
Có thể trả:
{
"data": {
"id": 1,
"title": "Laravel 13",
"slug": "laravel-13",
"excerpt": "Tìm hiểu Laravel 13",
"image": "posts/laravel-13.jpg",
"published_at": "2026-09-10T10:00:00Z",
"view_count": 120,
"author": {
"id": 1,
"name": "Admin"
},
"category": {
"id": 2,
"name": "Laravel",
"slug": "laravel"
}
}
}
Đây đã gần với một API thực tế hơn rất nhiều.
Controller:
public function index()
{
$posts = Post::published()
->with(['user', 'category'])
->latest('published_at')
->paginate(10);
return PostResource::collection($posts);
}
API:
GET /api/posts
Response:
{
"data": [
{
"id": 1,
"title": "Laravel 13",
"slug": "laravel-13",
"author": {
"id": 1,
"name": "Admin"
},
"category": {
"id": 2,
"name": "Laravel",
"slug": "laravel"
}
}
]
}
passwordĐây là một nguyên tắc bảo mật rất quan trọng.
Ví dụ User Model có:
name
email
password
remember_token
API public không nên trả:
{
"name": "Admin",
"email": "admin@example.com",
"password": "..."
}
UserResource chỉ nên định nghĩa những trường cần thiết:
return [
'id' => $this->id,
'name' => $this->name,
];
Resource vì vậy cũng là một lớp giúp kiểm soát dữ liệu được public.
Database:
view_count
Frontend có thể muốn:
views
Resource:
return [
'id' => $this->id,
'title' => $this->title,
'views' => $this->view_count,
];
JSON:
{
"id": 1,
"title": "Laravel 13",
"views": 120
}
Database không cần đổi.
API cũng không cần expose tên column giống database.
Ví dụ muốn API trả:
is_published
Trong Resource:
'is_published' => $this->status === 'published',
Kết quả:
{
"id": 1,
"title": "Laravel 13",
"status": "published",
"is_published": true
}
is_published không nhất thiết phải là một column trong database.
Resource có thể tạo ra field phục vụ API.
Ví dụ Post có:
image
Database lưu:
posts/abc.jpg
Nhưng frontend cần URL hoàn chỉnh.
Có thể xử lý:
'image_url' => $this->image
? asset('storage/' . $this->image)
: null,
Response:
{
"id": 1,
"title": "Laravel 13",
"image_url": "http://127.0.0.1:8000/storage/posts/abc.jpg"
}
Trong project thực tế, cách tạo URL nên thống nhất với hệ thống Storage mà chúng ta đã học ở Bài 39 — Storage.
Đôi khi một field chỉ nên xuất hiện trong một số trường hợp.
Ví dụ:
'admin_note' => $this->when(
$request->user()?->is_admin,
$this->admin_note
),
Ý tưởng:
User bình thường
↓
không có admin_note
Admin
↓
có admin_note
Đây là một ví dụ về conditional attributes trong Resource.
Trong những API lớn, kỹ thuật này giúp response linh hoạt hơn.
whenLoaded()Tương tự relationship:
'author' => new UserResource(
$this->whenLoaded('user')
),
Nếu user được eager load:
Post::with('user')
thì API có author.
Nếu không:
Post::query()
thì field relationship có thể không được đưa vào response.
Điều này giúp tránh việc Resource tự động truy cập relationship trong mọi trường hợp.
Blog CMS của chúng ta đã có:
Post::published()
Do đó Controller:
public function index()
{
$posts = Post::published()
->with(['user', 'category'])
->latest('published_at')
->paginate(10);
return PostResource::collection($posts);
}
Đây là một kiến trúc rất đẹp:
Scope
↓
lọc dữ liệu
Eager Loading
↓
lấy relationship
Resource
↓
định dạng JSON
Mỗi tầng có một nhiệm vụ.
Đây là phiên bản chúng ta có thể sử dụng trong project:
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PostResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'excerpt' => $this->excerpt,
'image' => $this->image,
'status' => $this->status,
'published_at' => $this->published_at,
'view_count' => $this->view_count,
'author' => new UserResource(
$this->whenLoaded('user')
),
'category' => new CategoryResource(
$this->whenLoaded('category')
),
];
}
}
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\PostResource;
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 PostResource::collection($posts);
}
public function show(Post $post)
{
$post->load(['user', 'category']);
return new PostResource($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 (new PostResource($post))
->response()
->setStatusCode(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 new PostResource($post);
}
public function destroy(Post $post)
{
$post->delete();
return response()->json([
'message' => 'Post deleted successfully'
]);
}
}
routes/api.php:
<?php
use App\Http\Controllers\Api\PostController;
use Illuminate\Support\Facades\Route;
Route::apiResource('posts', PostController::class);
Bây giờ:
GET /api/posts
GET /api/posts/{post}
POST /api/posts
PUT /api/posts/{post}
PATCH /api/posts/{post}
DELETE /api/posts/{post}
Vẫn là REST API như Bài 41.
Nhưng response bây giờ đi qua:
PostResource
Laravel hỗ trợ Route::apiResource() để tạo resource routes cho API và loại bỏ các route create / edit vốn phục vụ giao diện HTML.
Trong nhiều trường hợp:
PostResource::collection($posts)
là đủ.
Không cần tạo:
PostCollection.php
Tuy nhiên Laravel cũng cho phép tạo Resource Collection riêng:
php artisan make:resource Posts --collection
Khi đó có thể có:
app/Http/Resources/Posts.php
hoặc tên collection mà chúng ta chỉ định.
Resource Collection phù hợp khi muốn thêm logic hoặc metadata ở cấp toàn bộ collection, thay vì từng item.
Người mới học API chưa cần lạm dụng phần này.
Có thể gặp hai cách gọi:
API Resource
và:
JsonResource
Trong bài học cơ bản:
API Resource
↓
JsonResource
↓
PostResource
Ví dụ:
class PostResource extends JsonResource
Đây là loại Resource chúng ta đang sử dụng để biến Model thành JSON.
Laravel 13 hiện còn có thêm:
JSON:API Resources
Đây là khả năng hỗ trợ response theo chuẩn JSON:API, bao gồm resource object serialization, relationships, sparse fieldsets, links và các header phù hợp với JSON:API.
Điều này rất đáng chú ý vì Laravel 13 tiếp tục mở rộng khả năng xây dựng API.
Tuy nhiên:
Bài 42
↓
JsonResource
↓
Hiểu Resource trước
là hướng học phù hợp hơn cho người mới.
Sau khi đã hiểu:
Model
↓
Resource
↓
JSON
thì việc học các chuẩn API nâng cao sẽ dễ dàng hơn.
Giả sử frontend React cần:
{
"id": 1,
"title": "Laravel 13",
"author": {
"id": 1,
"name": "Admin"
}
}
Laravel Resource đảm bảo cấu trúc này ổn định.
Frontend không cần biết:
posts.user_id
posts.category_id
được lưu như thế nào trong database.
Frontend chỉ cần biết API contract:
GET /api/posts/1
trả về:
data.id
data.title
data.author
data.category
Đây là một lợi ích lớn của việc tách:
Database structure
khỏi:
API response structure
Có thể tưởng tượng:
DATABASE
│
▼
Post Model
│
▼
┌──────────────┐
│ PostResource │
└──────────────┘
│
┌────────┴────────┐
│ │
Giữ lại Bỏ đi
│ │
▼ ▼
title internal data
slug password
image dữ liệu không cần
author
category
│
▼
JSON
Đây là cách rất dễ nhớ:
Model lấy dữ liệu — Resource quyết định dữ liệu nào được đưa ra API.
Nhiều người viết:
public function index()
{
return Post::all();
}
và nghĩ:
API chạy rồi là xong.
Đúng ở mức demo.
Nhưng API thực tế còn phải quan tâm:
Response structure
Security
Relationships
Pagination
Validation
Authentication
Authorization
Versioning
Performance
Resource giải quyết một phần rất quan trọng trong số đó:
Response structure
Ví dụ:
Post::with([
'user',
'category',
'tags',
'comments',
'comments.user',
'comments.replies'
])->get();
Resource có thể trả được rất nhiều dữ liệu.
Nhưng không có nghĩa là chúng ta nên trả tất cả.
API nên trả đúng dữ liệu client cần.
Ví dụ:
Post List
chỉ cần:
id
title
slug
image
category
Không nhất thiết phải tải:
comments
replies
author details
tags
...
Đây là vấn đề thiết kế API và hiệu năng.
Kiến trúc hiện tại:
BLOG CMS
│
┌──────────────┴──────────────┐
│ │
Website API
│ │
web.php api.php
│ │
BlogController PostController
│ │
Blade PostResource
│ │
HTML JSON
│ │
└──────────────┬──────────────┘
│
Eloquent
│
MySQL
Chúng ta đã có một kiến trúc backend khá hoàn chỉnh.
Chạy:
php artisan make:resource PostResource
Tạo:
app/Http/Resources/PostResource.php
Resource chỉ trả:
id
title
slug
excerpt
image
published_at
view_count
Không trả toàn bộ Model.
php artisan make:resource UserResource
Chỉ trả:
id
name
php artisan make:resource CategoryResource
Trả:
id
name
slug
Trong PostResource:
'author' => new UserResource(
$this->whenLoaded('user')
),
'category' => new CategoryResource(
$this->whenLoaded('category')
),
API:
GET /api/posts
phải sử dụng:
paginate(10)
và:
PostResource::collection($posts)
Quan sát:
data
links
meta
Một lớp trung gian dùng để biến Eloquent Model thành dữ liệu JSON theo cấu trúc mà API mong muốn.
php artisan make:resource PostResource
app/Http/Resources/
JsonResource
public function toArray(Request $request): array
return new PostResource($post);
return PostResource::collection($posts);
$this->whenLoaded('user')
Hãy nhớ sự khác nhau:
BÀI 41
REST API
↓
Endpoint
HTTP Method
JSON
Controller
CRUD
và:
BÀI 42
API Resource
↓
Model
↓
Resource
↓
JSON
Hay đơn giản hơn:
Bài 41:
"Làm API như thế nào?"
Bài 42:
"API nên trả dữ liệu như thế nào?"
Chúng ta đã có:
REST API
↓
API Resource
↓
JSON
Nhưng hiện tại API vẫn chưa giải quyết một câu hỏi quan trọng:
Làm sao xác định ai đang gọi API?
Ví dụ:
GET /api/posts
có thể là API public.
Nhưng:
POST /api/posts
PUT /api/posts/1
DELETE /api/posts/1
thì cần xác thực người dùng.
Đây chính là lúc chúng ta bước sang:
Chúng ta sẽ tìm hiểu:
Laravel Sanctum
↓
API Token
↓
Authentication
↓
Protected API
và từ đó hoàn thiện phần API Authentication ở Bài 44.
x1
quay về MỤC LỤC
Cập nhật: 2026-09-19T16:03:19.110+07:00
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.
Hoặc chỉ cần: Hình in đẹp☑️
/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ế.
x1
quay về MỤC LỤC
Cập nhật: 2026-09-18T10:58:35.696+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