📘 Laravel 12 (2026) — BÀI 32 — LOCAL SCOPE & GLOBAL SCOPE TRONG ELOQUENT
Trong các bài trước, chúng ta thường viết Query trực tiếp trong Controller:$users = User::where('is_active', true) ->where('role', 'user') ...
Dành cho những ai chưa biết về lập trình hay các bạn muốn chuyển sang nghề thiết kế web
Dành cho những ai chưa biết về lập trình hay các bạn muốn chuyển sang nghề thiết kế web
Trong các bài trước, chúng ta thường viết Query trực tiếp trong Controller:
$users = User::where('is_active', true)
->where('role', 'user')
->latest()
->paginate(10);
Cách này hoàn toàn đúng.
Nhưng khi dự án lớn lên, những điều kiện truy vấn giống nhau sẽ xuất hiện ở rất nhiều Controller.
Ví dụ:
User::where('is_active', true)->get();
User::where('is_active', true)->latest()->get();
User::where('is_active', true)->paginate(10);
Lúc này chúng ta có thể đưa các điều kiện truy vấn thường dùng vào Scope.
Laravel 13 hỗ trợ hai loại chính:
Local Scope
Global Scope
Local Scope chỉ được sử dụng khi chúng ta chủ động gọi nó.
Global Scope tự động được áp dụng vào các query của Model. Laravel cũng sử dụng cơ chế Global Scope cho Soft Delete.
#[Scope]?Có thể hiểu đơn giản:
Scope
│
├── Local Scope
│ ↓
│ Chủ động gọi
│
└── Global Scope
↓
Tự động áp dụng
Scope giúp chúng ta đóng gói những điều kiện truy vấn thường xuyên sử dụng vào Model.
Thay vì viết:
User::where('is_active', true)->get();
nhiều lần, chúng ta có thể tạo:
User::active()->get();
Code sẽ ngắn và dễ đọc hơn.
Local Scope là một nhóm điều kiện Query được định nghĩa trong Model và chỉ được áp dụng khi chúng ta gọi Scope đó.
Ví dụ:
User::active()->get();
Ở đây:
active()
↓
Local Scope
↓
where('is_active', true)
Nếu không gọi:
User::active()
thì điều kiện đó không được áp dụng.
Laravel 13 định nghĩa Local Scope bằng attribute #[Scope].
Mở:
app/Models/User.php
Thêm:
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
Sau đó tạo Scope:
#[Scope]
protected function active(Builder $query): void
{
$query->where('is_active', true);
}
Bây giờ có thể sử dụng:
$users = User::active()->get();
Laravel sẽ thêm điều kiện:
where is_active = 1
vào Query.
#[Scope]?Đây là điểm quan trọng khi học Laravel 13.
Cách hiện đại:
#[Scope]
protected function active(Builder $query): void
{
$query->where('is_active', true);
}
Laravel 13 sử dụng Illuminate\Database\Eloquent\Attributes\Scope để đánh dấu method là Local Scope.
Trong các phiên bản Laravel cũ, chúng ta thường gặp cú pháp:
public function scopeActive($query)
{
return $query->where('is_active', true);
}
Cú pháp cũ có thể xuất hiện trong nhiều tutorial trên Internet, nhưng trong khóa học Laravel 13 này, chúng ta ưu tiên cú pháp hiện đại.
Sau khi có:
#[Scope]
protected function active(Builder $query): void
{
$query->where('is_active', true);
}
Controller có thể viết:
$users = User::active()->get();
Thay vì:
$users = User::where('is_active', true)->get();
Hai cách cho cùng mục đích.
Nhưng Scope có ưu điểm là điều kiện:
where('is_active', true)
được đặt trong Model và có thể tái sử dụng.
Ví dụ chúng ta thường xuyên cần User:
role = user
is_active = true
Có thể tạo:
#[Scope]
protected function normalUsers(Builder $query): void
{
$query
->where('role', 'user')
->where('is_active', true);
}
Sau đó:
$users = User::normalUsers()->get();
Controller không cần biết chi tiết điều kiện bên trong.
Đây là điểm rất mạnh của Local Scope.
Tạo:
#[Scope]
protected function active(Builder $query): void
{
$query->where('is_active', true);
}
và:
#[Scope]
protected function admin(Builder $query): void
{
$query->where('role', 'admin');
}
Có thể viết:
$users = User::active()
->admin()
->get();
Laravel sẽ kết hợp các điều kiện.
Có thể hình dung:
User
│
├── active()
│ ↓
│ is_active = 1
│
└── admin()
↓
role = admin
Kết quả tương đương:
where is_active = 1
and role = 'admin'
Laravel cho phép chain nhiều Local Scope với nhau.
Trong dự án Blog CMS của khóa học, chúng ta có:
users
────────────────────────────
id
name
email
role
is_active
created_at
updated_at
Chúng ta có thể tạo các Scope:
active()
inactive()
admin()
editor()
user()
Sau đó Controller rất dễ đọc.
Ví dụ:
$users = User::active()
->latest()
->paginate(10);
Hoặc:
$users = User::admin()
->latest()
->paginate(10);
Hoặc:
$users = User::active()
->admin()
->latest()
->paginate(10);
Không phải Scope nào cũng cố định.
Ví dụ chúng ta muốn tìm User theo Role.
Thay vì tạo:
admin()
editor()
user()
chúng ta có thể tạo một Scope nhận tham số.
#[Scope]
protected function role(Builder $query, string $role): void
{
$query->where('role', $role);
}
Sử dụng:
User::role('admin')->get();
hoặc:
User::role('editor')->get();
hoặc:
User::role('user')->get();
Laravel gọi đây là Dynamic Scopes. Các tham số của Scope được khai báo sau $query.
Ví dụ:
#[Scope]
protected function role(Builder $query, string $role): void
{
$query->where('role', $role);
}
Trong Controller:
$users = User::role($request->role)
->latest()
->paginate(10);
Chúng ta có thể xây dựng:
Dashboard
│
▼
Filter Role
│
├── admin
├── editor
└── user
│
▼
User::role()
│
▼
Database
Đây là cách rất phù hợp cho chức năng Filter Users.
Không chỉ User.
Ví dụ Model Post.
Bảng:
posts
────────────────────────
id
title
content
status
category_id
user_id
created_at
updated_at
Chúng ta thường xuyên cần:
published
draft
hidden
Có thể tạo:
#[Scope]
protected function published(Builder $query): void
{
$query->where('status', 'published');
}
và:
#[Scope]
protected function draft(Builder $query): void
{
$query->where('status', 'draft');
}
Sau đó:
Post::published()->latest()->get();
hoặc:
Post::draft()->latest()->get();
Trang chủ Blog chỉ cần bài viết đã Published.
Thay vì:
$posts = Post::where('status', 'published')
->latest()
->paginate(10);
Có thể viết:
$posts = Post::published()
->latest()
->paginate(10);
Nhìn vào Controller, chúng ta lập tức hiểu:
Lấy những bài viết đã xuất bản.
Ví dụ chúng ta có:
categories
────────────────────
id
name
is_active
Tạo:
#[Scope]
protected function active(Builder $query): void
{
$query->where('is_active', true);
}
Sau đó:
$categories = Category::active()->get();
Rất dễ đọc.
Không có Scope:
$posts = Post::where('status', 'published')
->whereNotNull('category_id')
->latest()
->paginate(10);
Có Scope:
$posts = Post::published()
->withCategory()
->latest()
->paginate(10);
Controller trở thành nơi điều phối nghiệp vụ, thay vì chứa quá nhiều điều kiện Query.
Khác với Local Scope.
Local Scope:
Chỉ chạy khi gọi
Global Scope:
Tự động chạy
Ví dụ:
User::all()
bình thường lấy tất cả User.
Nếu User có Global Scope:
User::all()
│
▼
Global Scope
│
▼
Tự động thêm điều kiện
Laravel sẽ tự động áp dụng điều kiện đó vào Query.
Giả sử chúng ta muốn hệ thống luôn luôn chỉ lấy User đang hoạt động.
Điều kiện:
where('is_active', true)
Nếu dùng Local Scope:
User::active()->get();
thì chúng ta phải nhớ gọi:
active()
Nhưng nếu dùng Global Scope:
User::all();
Laravel tự động thêm:
is_active = true
Đây chính là khác biệt quan trọng nhất.
Laravel 13 có thể tạo Scope class bằng Artisan:
php artisan make:scope ActiveUserScope
Laravel sẽ tạo Scope trong:
app/Models/Scopes
Đây là vị trí mặc định được Laravel sử dụng khi tạo Global Scope bằng Artisan.
File:
app/Models/Scopes/ActiveUserScope.php
Nội dung:
<?php
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class ActiveUserScope implements Scope
{
public function apply(
Builder $builder,
Model $model
): void {
$builder->where('is_active', true);
}
}
Scope class cần triển khai:
Illuminate\Database\Eloquent\Scope
và phương thức:
apply()
sẽ thêm điều kiện vào Query.
Laravel 13 hỗ trợ attribute #[ScopedBy].
Trong:
app/Models/User.php
thêm:
use App\Models\Scopes\ActiveUserScope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
Sau đó:
#[ScopedBy([ActiveUserScope::class])]
class User extends Authenticatable
{
//
}
Bây giờ:
User::all();
sẽ tự động áp dụng:
is_active = true
Laravel 13 hỗ trợ ScopedBy để khai báo Global Scope trực tiếp trên Model.
Giả sử database:
users
────────────────────────────
id | name | is_active
────────────────────────────
1 | A | 1
2 | B | 0
3 | C | 1
4 | D | 0
Khi:
User::all();
Global Scope sẽ tự động thêm:
is_active = true
Kết quả:
1 | A | 1
3 | C | 1
User:
2 | B | 0
4 | D | 0
không xuất hiện.
Đây là ví dụ rất dễ hiểu vì chúng ta vừa học Soft Delete ở Bài 30.
Khi Model sử dụng:
use SoftDeletes;
Laravel không đơn giản chỉ thay đổi cách delete() hoạt động.
Laravel còn sử dụng Global Scope để mặc định loại các record đã Soft Delete khỏi kết quả Query.
Ví dụ:
users
────────────────────────────
id | name | deleted_at
────────────────────────────
1 | A | NULL
2 | B | 2026-08-20
3 | C | NULL
Khi:
User::all();
kết quả mặc định:
A
C
User B không xuất hiện.
Đôi khi chúng ta cần lấy cả dữ liệu bị Global Scope loại bỏ.
Laravel cung cấp:
withoutGlobalScope()
Ví dụ:
User::withoutGlobalScope(
ActiveUserScope::class
)->get();
Lúc này Global Scope:
ActiveUserScope
sẽ không được áp dụng cho Query đó.
Nếu muốn bỏ toàn bộ Global Scope:
User::withoutGlobalScopes()->get();
Có thể hiểu:
User::all()
│
▼
Global Scopes
│
▼
Áp dụng
User::withoutGlobalScopes()
│
▼
Bỏ Global Scopes
Laravel 13 cũng hỗ trợ withoutGlobalScopes([...]) để chỉ bỏ một số Scope cụ thể.
Nếu Scope rất đơn giản, không nhất thiết phải tạo một class riêng.
Có thể khai báo trực tiếp trong Model:
protected static function booted(): void
{
static::addGlobalScope('active', function (Builder $builder) {
$builder->where('is_active', true);
});
}
Khi đó:
User::all();
vẫn tự động thêm:
is_active = true
Laravel hỗ trợ Anonymous Global Scope thông qua Closure.
Vì chúng ta đặt tên:
active
nên có thể bỏ Scope bằng:
User::withoutGlobalScope('active')->get();
Tên:
active
phải giống tên đã đăng ký trong:
addGlobalScope()
| Đặc điểm | Local Scope | Global Scope |
|---|---|---|
| Kích hoạt | Chủ động gọi | Tự động |
| Phạm vi | Query cụ thể | Mọi Query của Model |
| Mục đích | Tái sử dụng điều kiện | Luôn áp dụng một quy tắc |
| Có thể bỏ | Không cần bỏ | Có thể withoutGlobalScope() |
| Ví dụ | active() | Soft Delete |
Có thể nhớ:
LOCAL
↓
Tôi muốn thì tôi gọi
GLOBAL
↓
Laravel tự động gọi
Local Scope phù hợp khi điều kiện:
Được sử dụng nhiều lần.
Không phải Query nào cũng cần.
Có ý nghĩa rõ ràng.
Giúp Controller dễ đọc.
Ví dụ:
active()
published()
draft()
admin()
editor()
popular()
recent()
role()
Đây là trường hợp sử dụng phổ biến nhất.
Global Scope phù hợp với những quy tắc mà Model gần như luôn luôn phải tuân thủ.
Ví dụ:
Chỉ lấy dữ liệu active
Chỉ lấy dữ liệu thuộc tenant hiện tại
Chỉ lấy dữ liệu của một cửa hàng
Soft Delete
Nhưng cần cẩn thận.
Global Scope có thể khiến Developer mới khó hiểu:
User::all();
nhưng database không trả về tất cả record.
Lý do là phía sau Query đang có Global Scope.
Đây là nguyên tắc rất quan trọng.
Nếu chúng ta tạo quá nhiều Global Scope:
User
├── ActiveScope
├── RoleScope
├── CountryScope
├── DepartmentScope
├── PermissionScope
└── ...
một Query đơn giản:
User::all();
có thể trở nên rất khó đoán.
Vì vậy:
Local Scope nên là lựa chọn mặc định khi chỉ muốn tái sử dụng Query.
Global Scope chỉ nên dùng khi quy tắc đó thực sự cần áp dụng mặc định.
Với dự án chúng ta đang xây dựng, có thể tổ chức như sau:
User
├── active()
├── role()
└── admin()
Post
├── published()
├── draft()
├── hidden()
└── category()
Category
└── active()
Ví dụ trang quản trị:
$users = User::active()
->latest()
->paginate(10);
Trang Blog:
$posts = Post::published()
->latest()
->paginate(10);
Trang Draft:
$posts = Post::draft()
->latest()
->paginate(10);
Code rất dễ đọc.
Ví dụ trang Users có:
Search
Role
Status
Pagination
Chúng ta có thể xây dựng:
$users = User::query()
->active()
->role($request->role)
->latest()
->paginate(10);
Nếu cần thêm Search:
$users = User::query()
->active()
->role($request->role)
->search($request->search)
->latest()
->paginate(10);
Khi đó Model trở thành nơi chứa những Query có thể tái sử dụng.
So sánh:
$posts = Post::where('status', 'published')
->whereNotNull('category_id')
->where('is_active', true)
->orderByDesc('created_at')
->paginate(10);
với:
$posts = Post::published()
->withCategory()
->active()
->latest()
->paginate(10);
Cách thứ hai gần giống ngôn ngữ tự nhiên hơn:
Lấy Post
→ đã Published
→ có Category
→ đang Active
→ mới nhất
→ phân trang
Đó chính là giá trị lớn của Scope.
Ví dụ:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
#[Scope]
protected function active(Builder $query): void
{
$query->where('is_active', true);
}
#[Scope]
protected function role(
Builder $query,
string $role
): void {
$query->where('role', $role);
}
#[Scope]
protected function admin(Builder $query): void
{
$query->where('role', 'admin');
}
}
Controller:
$users = User::active()
->role('editor')
->latest()
->paginate(10);
Hoặc:
$admins = User::active()
->admin()
->latest()
->paginate(10);
Scope chỉ là cách đóng gói Query thường dùng.
Chúng ta vẫn có thể sử dụng:
where()
orderBy()
latest()
with()
paginate()
Ví dụ:
Post::published()
->where('category_id', 5)
->with('user')
->latest()
->paginate(10);
Scope và Query Builder có thể kết hợp với nhau.
Một nguyên tắc tốt:
MODEL
↓
Quy tắc truy vấn có thể tái sử dụng
CONTROLLER
↓
Điều phối Request → Query → View
Không nên biến Controller thành một nơi chứa hàng trăm dòng:
where()
where()
where()
where()
where()
nếu những điều kiện đó được sử dụng lặp lại nhiều lần.
Đưa chúng vào Scope sẽ giúp source dễ bảo trì hơn.
Local Scope:
#[Scope]
protected function active(Builder $query): void
{
$query->where('is_active', true);
}
Sử dụng:
User::active()->get();
Dynamic Scope:
#[Scope]
protected function role(
Builder $query,
string $role
): void {
$query->where('role', $role);
}
Sử dụng:
User::role('admin')->get();
Tạo:
php artisan make:scope ActiveUserScope
Scope:
class ActiveUserScope implements Scope
{
public function apply(
Builder $builder,
Model $model
): void {
$builder->where('is_active', true);
}
}
Gắn vào Model:
#[ScopedBy([ActiveUserScope::class])]
class User extends Authenticatable
{
//
}
Bây giờ:
User::all();
tự động áp dụng Scope.
Muốn bỏ:
User::withoutGlobalScope(
ActiveUserScope::class
)->get();
Tạo Local Scope:
active()
để lấy User:
is_active = 1
Sau đó sử dụng:
User::active()->get();
Tạo Dynamic Scope:
role()
Sử dụng:
User::role('admin')->get();
và:
User::role('editor')->get();
Trong Post Model tạo:
published()
để lấy:
status = published
Sau đó:
Post::published()
->latest()
->paginate(10);
Tạo:
draft()
để lấy:
status = draft
Sử dụng:
Post::draft()->get();
Viết Query:
User
→ active
→ role = editor
→ mới nhất
→ paginate 10
Kết quả mong muốn:
User::active()
->role('editor')
->latest()
->paginate(10);
SCOPE
│
├── LOCAL SCOPE
│ │
│ ├── #[Scope]
│ ├── Chủ động gọi
│ ├── Có thể chain
│ └── Có thể nhận tham số
│
└── GLOBAL SCOPE
│
├── Tự động áp dụng
├── #[ScopedBy]
├── Có thể dùng Scope class
└── Có thể bỏ bằng withoutGlobalScope()
Cách nhớ đơn giản nhất:
Local Scope
→ "Tôi gọi thì nó chạy."
Global Scope
→ "Nó tự chạy."
Accessor
→ "Đọc dữ liệu thì xử lý."
Mutator
→ "Ghi dữ liệu thì xử lý."
Scope
→ "Query thường dùng thì đóng gói."
Sau Bài 32, chúng ta đã có thể đưa những điều kiện Query lặp lại ra khỏi Controller và tổ chức chúng ngay trong Eloquent Model.
Đây là nền tảng rất quan trọng trước khi bước sang:
📘 Bài 33 — Observer
Ở bài tiếp theo, chúng ta sẽ tìm hiểu cách Laravel tự động thực hiện một hành động khi Model xảy ra các sự kiện như:
creating
created
updating
updated
deleting
deleted
restored
và xây dựng UserObserver / PostObserver cho dự án Blog CMS.
Trong các bài trước, chúng ta thường viết Query trực tiếp trong Controller:$users = User::where('is_active', true) ->where('role', 'user') ...
Nếu bạn muốn chọn từng từ giống nhau rồi sửa đồng loạt, có mấy phím rất tiện:
Ctrl + D
Ví dụ có:
$user
$user
$user
$userĐặt con trỏ vào $user → nhấn:
Ctrl + D → chọn $user kế tiếpCtrl + D → chọn tiếp cái nữaCtrl + D → ...
Sau đó gõ một lần → tất cả vị trí đã chọn sẽ thay đổi.
Ctrl + Shift + L
Ví dụ bạn bôi đen:
$userrồi nhấn:
Ctrl + Shift + L
→ VS Code chọn tất cả $user trong file.
Sau đó gõ nội dung mới → sửa đồng loạt.
Nếu Ctrl + D chọn nhầm một vị trí:
Ctrl + K rồi Ctrl + D
→ bỏ qua occurrence hiện tại, chuyển sang cái tiếp theo.
Bạn nói:
quét chọn 01 từ nó chọn tất cả từ giống vậy
Có thể bạn đang bấm Ctrl + Shift + L hoặc một phím tắt/extension đã gán nó.
Nếu mục đích của bạn là sửa hàng loạt có kiểm soát, mình khuyên dùng:
Ctrl + D → Ctrl + D → Ctrl + D...
Còn muốn sửa toàn bộ một lần:
bôi đen từ → Ctrl + Shift + L → gõ nội dung mới.
Nếu bạn muốn chọn từng từ giống nhau rồi sửa đồng loạt, có mấy phím rất tiện:🔥 Cách 1 — Chọn từ hiện tại rồi chọn tiếp từng từ giống nóCtrl + DVí dụ c...
Trong các bài CRUD trước, chúng ta đã làm việc trực tiếp với dữ liệu:
$user->name
$user->email
$user->role
Nhưng trong dự án thực tế, đôi khi chúng ta muốn biến đổi dữ liệu trước khi hiển thị hoặc xử lý dữ liệu trước khi lưu vào database.
Ví dụ:
Database lưu nguyen van a
Website muốn hiển thị Nguyen Van A
Hoặc:
Người dùng nhập NGUYEN VAN A
Database chỉ lưu nguyen van a
Đây chính là lúc Accessor và Mutator phát huy tác dụng.
Laravel 13 sử dụng cách khai báo hiện đại thông qua Illuminate\Database\Eloquent\Casts\Attribute.
Có thể hiểu đơn giản:
ELOQUENT MODEL
│
┌────────────┴────────────┐
│ │
ACCESSOR MUTATOR
│ │
Database → PHP PHP → Database
│ │
Khi đọc dữ liệu Khi ghi dữ liệu
│ │
▼ ▼
Biến đổi để hiển thị Biến đổi trước khi lưu
Accessor dùng để thay đổi giá trị khi chúng ta lấy dữ liệu ra.
Database
↓
Accessor
↓
PHP / Blade
Mutator dùng để thay đổi giá trị khi chúng ta gán dữ liệu vào Model.
PHP / Form
↓
Mutator
↓
Database
Giả sử bảng users có:
id | name
1 | nguyen van a
2 | tran van b
3 | le van c
Khi hiển thị trên website, chúng ta muốn:
Nguyen Van A
Tran Van B
Le Van C
Thay vì phải viết:
ucwords($user->name)
ở rất nhiều nơi:
{{ ucwords($user->name) }}
Chúng ta có thể xử lý ngay trong Model bằng Accessor.
Khi đó Blade chỉ cần:
{{ $user->name }}
Eloquent sẽ tự động xử lý giá trị khi đọc thuộc tính.
Trong Laravel hiện đại, chúng ta sử dụng:
use Illuminate\Database\Eloquent\Casts\Attribute;
Sau đó định nghĩa một method trong Model:
protected function name(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucwords($value),
);
}
Đây là cách khai báo Accessor hiện đại được Laravel hỗ trợ.
Mở:
app/Models/User.php
Ví dụ:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
protected function name(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucwords($value),
);
}
}
Bây giờ database có:
nguyen van a
Nhưng:
$user->name
sẽ trả về:
Nguyen Van A
Trong Blade:
{{ $user->name }}
cũng sẽ hiển thị:
Nguyen Van A
Khi chúng ta viết:
$user->name
Eloquent nhìn thấy Accessor:
protected function name(): Attribute
và thực hiện phần:
get: fn (string $value) => ucwords($value)
Có thể hình dung:
Database
────────────────────
name = "nguyen van a"
│
▼
Accessor
│
▼
ucwords()
│
▼
"Nguyen Van A"
Điều quan trọng:
Accessor không thay đổi dữ liệu trong database.
Database vẫn là:
nguyen van a
Chỉ khi lấy dữ liệu ra thì nó mới được biến đổi.
Mutator hoạt động theo chiều ngược lại.
Ví dụ người dùng nhập:
NGUYEN VAN A
Chúng ta muốn database lưu:
nguyen van a
Có thể sử dụng:
protected function name(): Attribute
{
return Attribute::make(
set: fn (string $value) => strtolower(trim($value)),
);
}
Khi thực hiện:
$user->name = ' NGUYEN VAN A ';
Mutator sẽ xử lý:
" NGUYEN VAN A "
│
▼
trim()
│
▼
"NGUYEN VAN A"
│
▼
strtolower()
│
▼
"nguyen van a"
│
▼
Database
Laravel gọi Mutator khi giá trị thuộc tính được gán.
Đây mới là cách sử dụng rất hay.
Chúng ta có thể vừa xử lý khi ghi vừa xử lý khi đọc.
protected function name(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucwords($value),
set: fn (string $value) => strtolower(trim($value)),
);
}
Khi lưu:
$user->name = ' NGUYEN VAN A ';
Database:
nguyen van a
Khi đọc:
$user->name
Kết quả:
Nguyen Van A
Như vậy:
User nhập dữ liệu
│
▼
MUTATOR
│
▼
Database
"nguyen van a"
│
▼
ACCESSOR
│
▼
"Nguyen Van A"
│
▼
Website
Một trường hợp rất thực tế là email.
Người dùng có thể nhập:
ADMIN@GMAIL.COM
Chúng ta muốn database lưu:
admin@gmail.com
Có thể viết:
protected function email(): Attribute
{
return Attribute::make(
set: fn (string $value) => strtolower(trim($value)),
);
}
Khi:
$user->email = ' ADMIN@GMAIL.COM ';
Database sẽ nhận:
admin@gmail.com
Điều này giúp dữ liệu trong hệ thống thống nhất hơn.
Accessor không nhất thiết phải xử lý một column có sẵn.
Chúng ta có thể tạo ra một thuộc tính ảo.
Ví dụ User có:
first_name
last_name
Database:
first_name = Nguyễn
last_name = Văn A
Chúng ta muốn:
$user->full_name
trả về:
Nguyễn Văn A
Có thể viết:
protected function fullName(): Attribute
{
return Attribute::make(
get: fn ($value, array $attributes) =>
$attributes['first_name'] . ' ' . $attributes['last_name'],
);
}
Sau đó:
{{ $user->full_name }}
Laravel sẽ trả về:
Nguyễn Văn A
full_name không cần tồn tại trong database.
$attributes?Trong Accessor:
get: fn ($value, array $attributes) => ...
Laravel cung cấp:
$attributes
chứa các giá trị gốc của Model.
Ví dụ:
$attributes['first_name']
$attributes['last_name']
Do đó chúng ta có thể tạo dữ liệu mới từ nhiều column.
Ví dụ:
protected function fullName(): Attribute
{
return Attribute::make(
get: fn ($value, array $attributes) =>
trim(
$attributes['first_name'] . ' ' .
$attributes['last_name']
),
);
}
Sau khi định nghĩa:
protected function fullName(): Attribute
{
return Attribute::make(
get: fn ($value, array $attributes) =>
$attributes['first_name'] . ' ' .
$attributes['last_name'],
);
}
Trong Blade:
<h2>{{ $user->full_name }}</h2>
Không cần:
<h2>
{{ $user->first_name }}
{{ $user->last_name }}
</h2>
Model sẽ chịu trách nhiệm xử lý dữ liệu.
Ví dụ:
protected function fullName(): Attribute
{
return Attribute::make(
get: fn ($value, array $attributes) =>
$attributes['first_name'] . ' ' .
$attributes['last_name'],
);
}
Chúng ta có:
$user->full_name
nhưng bảng users không nhất thiết phải có:
full_name
Đây là computed attribute.
Database
──────────────────────
first_name
last_name
│
▼
Accessor
│
▼
full_name
Rất hữu ích khi xây dựng API hoặc giao diện.
Mặc định, một Accessor tính toán không nhất thiết xuất hiện trong kết quả toArray() hoặc JSON.
Ví dụ:
return $user->toArray();
Nếu muốn thêm thuộc tính:
full_name
vào dữ liệu JSON/array, Laravel 13 hỗ trợ khai báo #[Appends(...)].
Ví dụ:
use Illuminate\Database\Eloquent\Attributes\Appends;
#[Appends(['full_name'])]
class User extends Authenticatable
{
protected function fullName(): Attribute
{
return Attribute::make(
get: fn ($value, array $attributes) =>
$attributes['first_name'] . ' ' .
$attributes['last_name'],
);
}
}
Khi chuyển Model sang JSON, full_name sẽ được thêm vào.
Giả sử users có:
name
email
role
is_active
Chúng ta có thể tạo Accessor cho trạng thái:
protected function statusText(): Attribute
{
return Attribute::make(
get: fn ($value, array $attributes) =>
$attributes['is_active']
? 'Đang hoạt động'
: 'Đã khóa',
);
}
Trong Blade:
{{ $user->status_text }}
Nếu:
is_active = 1
sẽ hiển thị:
Đang hoạt động
Nếu:
is_active = 0
sẽ hiển thị:
Đã khóa
Chúng ta cũng có thể biến:
admin
user
editor
thành:
Quản trị viên
Người dùng
Biên tập viên
Ví dụ:
protected function roleText(): Attribute
{
return Attribute::make(
get: fn ($value, array $attributes) => match ($attributes['role']) {
'admin' => 'Quản trị viên',
'editor' => 'Biên tập viên',
'user' => 'Người dùng',
default => 'Không xác định',
},
);
}
Blade:
{{ $user->role_text }}
Kết quả:
admin → Quản trị viên
editor → Biên tập viên
user → Người dùng
$casts như thế nào?Đây là điểm rất quan trọng.
Laravel có một cơ chế khác là Attribute Casting.
Ví dụ:
protected function casts(): array
{
return [
'is_active' => 'boolean',
];
}
Khi đó:
$user->is_active
sẽ được chuyển thành kiểu:
true
hoặc:
false
Laravel cung cấp rất nhiều kiểu cast như:
boolean
integer
string
array
json
date
datetime
decimal
encrypted
hashed
enum
và nhiều kiểu khác.
Dùng Accessor khi cần:
Database
↓
Biến đổi
↓
Hiển thị / sử dụng
Ví dụ:
name
full_name
role_text
status_text
price_formatted
avatar_url
Ví dụ:
$user->status_text
hoặc:
$post->formatted_price
Dùng Mutator khi muốn chuẩn hóa dữ liệu trước khi lưu.
Ví dụ:
trim()
strtolower()
strtoupper()
chuẩn hóa dữ liệu
Ví dụ:
protected function email(): Attribute
{
return Attribute::make(
set: fn (string $value) => strtolower(trim($value)),
);
}
Khi người dùng nhập:
ADMIN@GMAIL.COM
Database lưu:
admin@gmail.com
Trong CRUD Users ở Bài 28, Controller có thể vẫn rất đơn giản:
$user->update($request->validated());
Không cần xử lý:
trim()
strtolower()
ucwords()
trong Controller nếu những quy tắc đó thuộc về Model.
Ví dụ:
$user->email = $request->email;
Mutator tự xử lý:
trim()
strtolower()
Sau đó:
$user->save();
Điều này giúp Controller sạch hơn.
Accessor rất tiện nhưng không nên lạm dụng.
Ví dụ không nên đưa những logic quá nặng:
protected function something(): Attribute
{
return Attribute::make(
get: fn () => DB::table(...)->get(...),
);
}
Nếu Accessor thực hiện query database phức tạp, khi hiển thị danh sách hàng trăm User có thể gây ra vấn đề hiệu năng.
Accessor nên ưu tiên:
Xử lý đơn giản
Tính toán đơn giản
Format dữ liệu
Tạo giá trị hiển thị
Trong Laravel hiện đại, chúng ta ưu tiên:
protected function name(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucwords($value),
set: fn (string $value) => strtolower(trim($value)),
);
}
Thay vì kiểu cú pháp cũ:
public function getNameAttribute($value)
{
return ucwords($value);
}
public function setNameAttribute($value)
{
$this->attributes['name'] = strtolower(trim($value));
}
Cú pháp Attribute::make() rõ ràng hơn và phù hợp với Laravel hiện đại.
Một Model có thể viết:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
protected $fillable = [
'name',
'email',
'role',
'is_active',
];
protected function name(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucwords($value),
set: fn (string $value) =>
strtolower(trim($value)),
);
}
protected function email(): Attribute
{
return Attribute::make(
set: fn (string $value) =>
strtolower(trim($value)),
);
}
protected function roleText(): Attribute
{
return Attribute::make(
get: fn ($value, array $attributes) => match ($attributes['role']) {
'admin' => 'Quản trị viên',
'editor' => 'Biên tập viên',
'user' => 'Người dùng',
default => 'Không xác định',
},
);
}
protected function statusText(): Attribute
{
return Attribute::make(
get: fn ($value, array $attributes) =>
$attributes['is_active']
? 'Đang hoạt động'
: 'Đã khóa',
);
}
}
Trong Blade:
{{ $user->name }}
{{ $user->email }}
{{ $user->role_text }}
{{ $user->status_text }}
Giả sử database:
name = "nguyen van a"
email = " ADMIN@GMAIL.COM "
role = "admin"
is_active = 1
Khi đọc:
$user->name
Kết quả:
Nguyen Van A
Khi đọc:
$user->email
Kết quả:
ADMIN@GMAIL.COM
Nếu chỉ khai báo Mutator cho email thì dữ liệu sẽ được chuẩn hóa khi lưu. Sau khi lưu, giá trị database sẽ là:
admin@gmail.com
Role:
$user->role_text
Kết quả:
Quản trị viên
Status:
$user->status_text
Kết quả:
Đang hoạt động
Có thể nhớ Accessor và Mutator bằng một câu:
ACCESSOR = Đọc dữ liệu → biến đổi → sử dụng
MUTATOR = Ghi dữ liệu → biến đổi → lưu
| Thành phần | Thời điểm | Mục đích |
|---|---|---|
| Accessor | Khi đọc | Format / tính toán dữ liệu |
| Mutator | Khi ghi | Chuẩn hóa dữ liệu |
| Cast | Khi đọc/ghi | Chuyển đổi kiểu dữ liệu |
Ví dụ:
MODEL
│
┌───────────┴───────────┐
│ │
ACCESSOR MUTATOR
│ │
▼ ▼
Khi đọc Khi ghi
│ │
▼ ▼
Format dữ liệu Chuẩn hóa dữ liệu
│ │
▼ ▼
Blade Database
Trong Model User, hãy tạo:
Mutator cho email:
ADMIN@GMAIL.COM
→
admin@gmail.com
Accessor cho name:
nguyen van a
→
Nguyen Van A
Accessor:
$user->role_text
Hiển thị:
admin → Quản trị viên
user → Người dùng
editor → Biên tập viên
Accessor:
$user->status_text
Hiển thị:
is_active = 1 → Đang hoạt động
is_active = 0 → Đã khóa
Tạo Accessor:
$user->display_name
Nếu name có dữ liệu thì hiển thị name, nếu không thì hiển thị:
Chưa cập nhật tên
Accessor
↓
Xử lý khi lấy dữ liệu
Mutator
↓
Xử lý khi gán dữ liệu
Attribute::make()
↓
Cú pháp hiện đại trong Laravel
$casts
↓
Chuyển đổi kiểu dữ liệu
Model
↓
Có thể chịu trách nhiệm format
và chuẩn hóa dữ liệu
Sau Bài 31, chúng ta đã biết cách để đưa logic xử lý thuộc tính vào Eloquent Model thay vì nhồi tất cả vào Controller hoặc Blade.
Đây là một bước quan trọng để source Laravel bắt đầu sạch và có cấu trúc hơn.
Bài tiếp theo — Bài 32: Local Scope & Global Scope.
x0
quay về MỤC LỤC
Trong các bài CRUD trước, chúng ta đã làm việc trực tiếp với dữ liệu:$user->name $user->email $user->role Nhưng trong dự án thực tế, đôi khi ...
Ở các bài CRUD trước, khi gọi:
$user->delete();
record sẽ bị xóa khỏi database.
Ví dụ:
users
────────────────────────
id | name
1 | Nguyễn Văn A
2 | Trần Văn B
3 | Lê Văn C
Xóa User id = 2:
$user->delete();
thì record có thể biến mất hoàn toàn:
users
────────────────────────
id | name
1 | Nguyễn Văn A
3 | Lê Văn C
Trong hệ thống thực tế, cách này khá nguy hiểm.
Ví dụ Admin vô tình xóa một bài viết quan trọng. Chúng ta sẽ muốn:
🗑️ Xóa
↓
📦 Đưa vào Thùng rác
↓
♻️ Có thể khôi phục
Đây chính là Soft Delete.
deleted_atSoftDeletes vào Modeldelete() lúc này hoạt động thế nào?withTrashed()onlyTrashed()forceDelete()delete() và forceDelete()forceDelete()Soft Delete là cơ chế đánh dấu một record đã bị xóa thay vì xóa thật khỏi database.
Laravel sử dụng cột:
deleted_at
Ví dụ:
posts
────────────────────────────────────
id | title | deleted_at
1 | Bài viết 1 | NULL
2 | Bài viết 2 | 2026-08-18 10:30:00
3 | Bài viết 3 | NULL
Trong đó:
deleted_at = NULL
→ record đang hoạt động.
deleted_at = thời gian
→ record đã bị Soft Delete.
Soft Delete đặc biệt hữu ích với:
Users
Posts
Categories
Products
Orders
Customers
Documents
Comments
Đặc biệt với Admin CMS:
Xóa
↓
Trash
↓
Restore
thường an toàn hơn xóa vĩnh viễn.
deleted_atTạo migration:
php artisan make:migration add_deleted_at_to_users_table
Mở migration:
Schema::table('users', function (Blueprint $table) {
$table->softDeletes();
});
Laravel sẽ tạo:
deleted_at
với kiểu dữ liệu phù hợp.
Sau đó:
php artisan migrate
Nếu muốn áp dụng cho Posts:
php artisan make:migration add_deleted_at_to_posts_table
Migration:
Schema::table('posts', function (Blueprint $table) {
$table->softDeletes();
});
Sau đó:
php artisan migrate
SoftDeletes vào ModelĐây là bước quan trọng.
Mở:
app/Models/User.php
Thêm:
use Illuminate\Database\Eloquent\SoftDeletes;
Sau đó:
class User extends Authenticatable
{
use SoftDeletes;
}
Model hoàn chỉnh có thể:
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
use HasFactory;
use SoftDeletes;
}
delete() lúc này hoạt động thế nào?Khi:
$user->delete();
Laravel không xóa record khỏi database.
Thay vào đó:
deleted_at = thời gian hiện tại
Ví dụ:
id | name | deleted_at
1 | Nguyễn Văn A | NULL
2 | Trần Văn B | 2026-08-18 11:00:00
User id = 2 vẫn tồn tại trong database.
Laravel tự động loại các record Soft Delete khỏi những truy vấn Eloquent thông thường.
Ví dụ:
$users = User::all();
sẽ chỉ lấy:
deleted_at IS NULL
Do đó User đã đưa vào Trash sẽ không xuất hiện trong danh sách bình thường.
Đây là một trong những ưu điểm lớn của Soft Delete.
withTrashed()Nếu muốn lấy cả record đang hoạt động và đã xóa:
$users = User::withTrashed()->get();
//hoặc
$users = User::query()
// SOFT DELETE
->withTrashed()
Kết quả:
User bình thường
+
User đã Soft Delete
onlyTrashed()Nếu chỉ muốn lấy những record đã xóa:
$users = User::onlyTrashed()->get();
Đây chính là dữ liệu cho trang:
🗑️ Trash
Ví dụ:
public function trash()
{
$users = User::onlyTrashed()->latest('deleted_at')->paginate(10);
return view('users.trash', compact('users'));
}
Muốn khôi phục User:
$user->restore();
Laravel sẽ đưa:
deleted_at
về:
NULL
Ví dụ:
Trước:
id | name | deleted_at
2 | ABC | 2026-08-18
Sau restore:
id | name | deleted_at
2 | ABC | NULL
User xuất hiện trở lại trong danh sách bình thường.
Trong:
routes/web.php
thêm:
Route::patch(
'users/{user}/restore',
[UserController::class, 'restore']
)->name('users.restore');
Trong UserController:
public function restore($id)
{
$user = User::withTrashed()->findOrFail($id);
$user->restore();
return redirect()
->route('admin.users.index')
->with('success', 'Khôi phục User thành công.');
}
Chú ý:
withTrashed()
rất quan trọng.
Nếu không có nó, User đã Soft Delete sẽ không được tìm thấy bởi truy vấn Eloquent thông thường.
Blade:
{{-- KHÔI PHỤC --}}
@if ($user->trashed())
<form
action="{{ route('admin.users.restore', $user->id) }}"
method="POST"
class="inline">
@csrf
@method('PATCH')
<button
type="submit"
class="px-3 py-1 bg-green-600 text-white rounded hover:bg-green-700">
♻️ Khôi phục
</button>
</form>
@endif
forceDelete()Soft Delete không xóa thật.
Nếu muốn xóa vĩnh viễn:
$user->forceDelete();
Record sẽ thực sự bị xóa khỏi database.
Ví dụ:
Soft Delete:
id | name | deleted_at
2 | ABC | 2026-08-18
Sau:
$user->forceDelete();
record id = 2 không còn tồn tại.
delete() và forceDelete()| Method | Kết quả |
|---|---|
delete() | Soft Delete |
restore() | Khôi phục |
forceDelete() | Xóa vĩnh viễn |
withTrashed() | Lấy cả record đã xóa |
onlyTrashed() | Chỉ lấy record đã xóa |
Đây là 5 method cần nhớ.
Chú ý: khi softdelete() thì posts của user đó vẫn còn -> cần sửa post model
Muốn lấy được cả User đã bị xóa:
public function user()
{
return $this->belongsTo(User::class)
->withTrashed();
}
Route (phải đặt trước restore và resource):
Route::get(
'users/trash',
[UserController::class, 'trash']
)->name('users.trash');
Controller:
public function trash()
{
$users = User::onlyTrashed()
->latest('deleted_at')
->paginate(10);
return view('admin.users.trash', compact('users'));
}
Ví dụ:
<table class="w-full">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Email</th>
<th>Deleted At</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@foreach ($users as $user)
<tr>
<td>{{ $user->id }}</td>
<td>{{ $user->name }}</td>
<td>{{ $user->email }}</td>
<td>
{{ $user->deleted_at }}
</td>
<td>
<form
method="POST"
action="{{ route('admin.users.restore', $user->id) }}"
>
@csrf
@method('PATCH')
<button type="submit">
♻️ Restore
</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
{{ $users->links() }}
Trong Trash:
<form
method="POST"
action="{{ route('admin.users.forceDelete', $user->id) }}"
>
@csrf
@method('DELETE')
<button type="submit">
🗑️ Xóa vĩnh viễn
</button>
</form>
Route:
Route::delete(
'users/{user}/force-delete',
[UserController::class, 'forceDelete']
)->name('users.forceDelete');
forceDelete()public function forceDelete($id)
{
$user = User::onlyTrashed()->findOrFail($id);
$user->forceDelete();
return redirect()
->route('admin.users.trash')
->with('success', 'Đã xóa vĩnh viễn.');
}
Ở đây sử dụng:
onlyTrashed()
để đảm bảo chỉ xóa vĩnh viễn những record đang nằm trong Trash.
Có thể xóa nhiều record:
User::onlyTrashed()->forceDelete();
Hoặc:
User::onlyTrashed()->each(function ($user) {
$user->forceDelete();
});
Tuy nhiên với dữ liệu lớn, cần cân nhắc cách xử lý để tránh tải quá nhiều model vào memory.
UserController:
public function forceDeleteAll()
{
User::onlyTrashed()->forceDelete();
return redirect()
->route('admin.users.trash')
->with('success', 'Đã xóa vĩnh viễn tất cả thùng rác.');
}
Route:
Route::delete(
'users/force-delete-all',
[UserController::class, 'forceDeleteAll']
)->name('users.forceDeleteAll');
Phần trash.blade.php xem phần 21
Có thể khôi phục nhiều record:
User::onlyTrashed()
->whereIn('id', $ids)
->restore();
Ví dụ:
ids = [5, 8, 12]
sẽ khôi phục ba User.
UserController
public function restoreMany(Request $request)
{
$ids = $request->input('ids', []);
User::onlyTrashed()
->whereIn('id', $ids)
->restore();
return redirect()
->route('admin.users.trash')
->with('success', 'Khôi phục User thành công.');
}
Route
Route::patch(
'users/restore-many',
[UserController::class, 'restoreMany']
)->name('users.restoreMany');
<x-app-layout>
<div class="max-w-7xl mx-auto py-8">
<x-ui.card
title="Thùng rác Users"
description="Quản lý các User đã bị Soft Delete">
{{-- ACTION --}}
<div class="flex justify-between items-center mb-4">
{{-- RESTORE MANY --}}
<form
method="POST"
action="{{ route('admin.users.restoreMany') }}"
id="restoreForm">
@csrf
@method('PATCH')
<button
type="submit"
onclick="return confirm('Bạn có chắc muốn khôi phục các User đã chọn?')"
class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
♻️ Khôi phục đã chọn
</button>
</form>
{{-- DELETE ALL --}}
<form
method="POST"
action="{{ route('admin.users.forceDeleteAll') }}"
onsubmit="return confirm('⚠️ Bạn có chắc muốn XÓA VĨNH VIỄN toàn bộ User trong thùng rác? Hành động này không thể hoàn tác!')">
@csrf
@method('DELETE')
<button
type="submit"
class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700">
🗑️ Xóa vĩnh viễn tất cả
</button>
</form>
</div>
{{-- TABLE --}}
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b">
{{-- SELECT ALL --}}
<th class="p-3 text-left">
<input
type="checkbox"
id="selectAll"
class="w-4 h-4">
</th>
<th class="p-3 text-left">
ID
</th>
<th class="p-3 text-left">
Name
</th>
<th class="p-3 text-left">
Email
</th>
<th class="p-3 text-left">
Deleted At
</th>
<th class="p-3 text-left">
Action
</th>
</tr>
</thead>
<tbody>
@forelse ($users as $user)
<tr class="border-b">
{{-- CHECKBOX --}}
<td class="p-3">
<input
type="checkbox"
name="ids[]"
value="{{ $user->id }}"
form="restoreForm"
class="user-checkbox w-4 h-4">
</td>
{{-- ID --}}
<td class="p-3">
{{ $user->id }}
</td>
{{-- NAME --}}
<td class="p-3">
{{ $user->name }}
</td>
{{-- EMAIL --}}
<td class="p-3">
{{ $user->email }}
</td>
{{-- DELETED AT --}}
<td class="p-3">
{{ $user->deleted_at }}
</td>
{{-- ACTION --}}
<td class="p-3">
<div class="flex gap-2">
{{-- RESTORE --}}
<form
method="POST"
action="{{ route('admin.users.restore', $user->id) }}">
@csrf
@method('PATCH')
<button
type="submit"
class="px-3 py-1 bg-green-600 text-white rounded hover:bg-green-700">
♻️ Restore
</button>
</form>
{{-- FORCE DELETE --}}
<form
method="POST"
action="{{ route('admin.users.forceDelete', $user->id) }}"
onsubmit="return confirm('⚠️ Xóa vĩnh viễn User này? Không thể hoàn tác!')">
@csrf
@method('DELETE')
<button
type="submit"
class="px-3 py-1 bg-red-600 text-white rounded hover:bg-red-700">
🗑️ Xóa vĩnh viễn
</button>
</form>
</div>
</td>
</tr>
@empty
<tr>
<td
colspan="6"
class="p-6 text-center text-gray-500">
🗑️ Thùng rác đang trống.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
{{-- PAGINATION --}}
<div class="mt-4">
{{ $users->links() }}
</div>
</x.ui.card>
</div>
{{-- SELECT ALL --}}
<script>
const selectAll = document.getElementById('selectAll');
const checkboxes = document.querySelectorAll('.user-checkbox');
selectAll?.addEventListener('change', function () {
checkboxes.forEach(checkbox => {
checkbox.checked = this.checked;
});
});
</script>
</x-app-layout>
Trang Users có thể thêm:
<a href="{{ route('admin.users.trash') }}">
🗑️ Thùng rác
</a>
Giao diện:
Users
[+ Thêm User] [🗑️ Thùng rác]
-------------------------------------
| ID | Name | Email | Role | Action |
-------------------------------------
USERS
│
│ delete()
↓
Soft Delete
│
↓
deleted_at
│
↓
TRASH
↙ ↘
restore() forceDelete()
↓ ↓
Users Xóa thật
Posts cũng tương tự.
Bước 1: thêm withTrash() trong PostController (xem lại mục 8)
Bước 2: thêm route store (xem lại mục 11)
Bước 3: thêm hàm restore trong controller (mục 12)
Bước 4: thêm nút restore trong view (xem lại phần 13)
Bước 5: xem lại 16->22 để tạo thêm trang Trash hoàn chỉnh cho posts
Model:
use Illuminate\Database\Eloquent\SoftDeletes;
class Post extends Model
{
use SoftDeletes;
}
Migration:
$table->softDeletes();
Xóa:
$post->delete();
Trash:
Post::onlyTrashed()->get();
Restore:
$post->restore();
Xóa vĩnh viễn:
$post->forceDelete();
Ví dụ User có Posts:
$user->posts
Các Post đã Soft Delete thông thường sẽ không xuất hiện.
Nếu muốn lấy cả Posts đã xóa:
$user->posts()->withTrashed()->get();
Chỉ lấy Posts đã xóa:
$user->posts()->onlyTrashed()->get();
Điều này rất quan trọng khi xây dựng hệ thống CMS có nhiều relationship.
Có thể sử dụng:
$user->trashed();
Nếu:
true
→ User đang bị Soft Delete.
Nếu:
false
→ User đang hoạt động.
Ví dụ:
if ($user->trashed()) {
// User đang nằm trong Trash
}
Đây là điểm rất quan trọng.
Soft Delete:
Database
↓
deleted_at
không phải:
Backup Database
Nếu database bị:
mất dữ liệu
hỏng ổ đĩa
DROP TABLE
server lỗi
thì Soft Delete không thể bảo vệ dữ liệu.
Vì vậy dự án thực tế vẫn cần:
Database Backup
+
Soft Delete
Không phải bảng nào cũng cần.
Ví dụ một số dữ liệu tạm thời hoặc dữ liệu không cần khôi phục có thể xóa trực tiếp.
Soft Delete cũng làm database giữ lại record đã xóa, vì vậy bảng có lượng dữ liệu rất lớn cần có chiến lược dọn dẹp phù hợp.
Đối với Blog CMS trong khóa học này, có thể áp dụng:
Users
↓
Soft Delete
Posts
↓
Soft Delete
Categories
↓
Soft Delete
Giao diện Admin:
Users
├── Active
└── Trash
Posts
├── Published
├── Draft
└── Trash
Categories
├── Active
└── Trash
Sau bài này, chúng ta đã biết:
Soft Delete
↓
deleted_at
Các method quan trọng:
delete()
→ đưa vào Trash.
restore()
→ khôi phục.
forceDelete()
→ xóa vĩnh viễn.
withTrashed()
→ lấy cả record đã xóa.
onlyTrashed()
→ chỉ lấy record đã xóa.
trashed()
→ kiểm tra record có đang bị Soft Delete hay không.
Mô hình hoàn chỉnh:
CRUD
│
↓
Users
│
┌────┴────┐
↓ ↓
Active Trash
│
┌────┴────┐
↓ ↓
Restore Force Delete
│
↓
Active
Thêm Soft Delete cho users.
Thêm Soft Delete cho posts.
Thêm nút 🗑️ Thùng rác.
Hiển thị danh sách record đã xóa.
Thêm ♻️ Restore.
Thêm 🗑️ Xóa vĩnh viễn.
Thêm xác nhận trước khi forceDelete().
Thêm Pagination cho Trash.
Thử kết hợp Trash với DataTables ở Bài 29.
Bài tiếp theo — Bài 31: Accessor & Mutator sẽ tìm hiểu cách Laravel biến đổi dữ liệu khi đọc/ghi Model, ví dụ định dạng tên, tạo thuộc tính ảo và tự động xử lý dữ liệu trước khi lưu vào database.
x1
quay về MỤC LỤC
Ở các bài CRUD trước, khi gọi:$user->delete(); record sẽ bị xóa khỏi database.Ví dụ:users ──────────────────────── id | name 1 | Nguyễn Văn A 2 |...
Trong các bài trước, chúng ta đã xây dựng được các chức năng CRUD cho:
Categories
Posts
Users
Tuy nhiên, khi dữ liệu bắt đầu nhiều lên, một bảng kiểu:
User::all();sẽ nhanh chóng trở nên bất tiện.
Ví dụ hệ thống có 10.000 User:
Không thể hiển thị tất cả trên một trang.
Người quản trị cần tìm User theo tên/email.
Cần sắp xếp theo tên, ngày đăng ký...
Cần lọc theo Role.
Cần phân trang.
Và nếu dữ liệu lớn, nên để Database xử lý Search / Sort / Filter / Pagination thay vì tải toàn bộ dữ liệu về trình duyệt.
Trong Laravel 13, Query Builder/Eloquent hỗ trợ trực tiếp các cơ chế phân trang như paginate(), simplePaginate() và cursorPaginate(). (Laravel API)
Sau bài này, chúng ta sẽ xây dựng trang:
Dashboard
└── Users
├── 🔎 Search
├── 🎯 Filter Role
├── ↕ Sort
├── 📄 Pagination
└── 📊 DataTablesNgười học sẽ hiểu được hai cách:
Browser
↓
Request
↓
Laravel
↓
MySQL
↓
paginate()
↓
BladeBrowser
↓
DataTables
↓ AJAX
Laravel
↓
MySQL
↓
JSON
↓
DataTablesVới dữ liệu rất lớn, DataTables có cơ chế server-side processing, tức là việc tìm kiếm, sắp xếp và phân trang được thực hiện phía server/database thay vì tải toàn bộ dữ liệu về browser. (DataTables Plus)
Trong dự án Laravel 13 của chúng ta đã có bảng:
usersVí dụ:
| id | name | role | |
|---|---|---|---|
| 1 | Nguyễn Văn A | a@gmail.com | admin |
| 2 | Trần Văn B | b@gmail.com | user |
| 3 | Lê Văn C | c@gmail.com | editor |
Model:
app/Models/User.phpVí dụ:
class User extends Authenticatable
{
protected $fillable = [
'name',
'email',
'password',
'role',
];
}Nếu chưa có:
php artisan make:controller UserController --resourceLaravel sẽ tạo:
app/Http/Controllers/UserController.phpController có các method:
index()
create()
store()
show()
edit()
update()
destroy()Trong bài này chúng ta tập trung vào:
index()
p/S: vì bài trước đã xong phần này nên rename file cũ và tạo lại file index.blade.php
Mở:
routes/web.phpThêm:
use App\Http\Controllers\UserController;
Route::resource('users', UserController::class);Sau đó:
php artisan route:list
//php artisan route:list --path=admin
Chúng ta sẽ có:
GET /users
POST /users
GET /users/create
GET /users/{user}
GET /users/{user}/edit
PUT /users/{user}
DELETE /users/{user}Trang danh sách User:
/userssẽ chạy:
UserController@indexĐầu tiên thử cách đơn giản nhất.
Trong:
UserController.phpviết:
use App\Models\User;
public function index()
{
$users = User::latest()->paginate(10);
return view('admin.users.index', compact('users'));
}Ý nghĩa:
paginate(10)là:
Mỗi trang tối đa 10 User.
Ví dụ có 53 User:
Trang 1 → 10 User
Trang 2 → 10 User
Trang 3 → 10 User
Trang 4 → 10 User
Trang 5 → 10 User
Trang 6 → 3 UserLaravel cung cấp paginate() để tạo paginator đầy đủ thông tin tổng số bản ghi; ngoài ra còn có simplePaginate() và cursorPaginate() cho những trường hợp phù hợp khác. (Laravel API)
Trong:
resources/views/users/index.blade.phpsau bảng:
<div class="mt-4">
{{ $users->links() }}
</div>Ví dụ:
<table class="w-full">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Email</th>
<th>Role</th>
</tr>
</thead>
<tbody>
@foreach ($users as $user)
<tr>
<td>{{ $user->id }}</td>
<td>{{ $user->name }}</td>
<td>{{ $user->email }}</td>
<td>{{ $user->role }}</td>
</tr>
@endforeach
</tbody>
</table>
<div class="mt-4">
{{ $users->links() }}
</div>Bây giờ chúng ta thêm ô tìm kiếm.
Giao diện:
┌─────────────────────────────────────────────┐
│ 🔎 Tìm User... [Search] │
└─────────────────────────────────────────────┘Blade:
<form method="GET" action="{{ route('admin.users.index') }}">
<input
type="text"
name="search"
value="{{ request('search') }}"
placeholder="Tìm tên hoặc email..."
>
<button type="submit">
Tìm kiếm
</button>
</form>Khi người dùng nhập:
nguyenURL sẽ trở thành:
/users?search=nguyenSửa:
public function index()
{
$users = User::latest()->paginate(10);
return view('users.index', compact('users'));
}thành:
public function index()
{
$query = User::query();
if (request('search')) {
$search = request('search');
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
$users = $query
->latest()
->paginate(10)
->withQueryString();
return view('admin.users.index', compact('users'));
}Đây là một pattern cực kỳ quan trọng:
$query = User::query();Sau đó xây dựng query từng bước.
withQueryString()?Nếu tìm:
nguyentrang đầu tiên có URL:
/users?search=nguyenKhi chuyển sang trang 2, chúng ta muốn:
/users?page=2&search=nguyenchứ không phải:
/users?page=2Vì vậy:
->withQueryString()giúp giữ lại các query parameter hiện tại.
Chúng ta có thể tìm:
name
emailbằng:
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});SQL tương đương về ý tưởng:
WHERE
name LIKE '%nguyen%'
OR email LIKE '%nguyen%'Đây là lý do phải sử dụng:
where(function () {})để nhóm điều kiện OR lại với nhau.
Bây giờ thêm bộ lọc:
Role:
[ Tất cả ▼ ]
[ admin ]
[ editor ]
[ user ]Blade:
{{-- Lọc theo role --}}
<form action="{{ route('admin.users.index') }}" method="get">
<select name="role">
<option value="">Tất cả Role</option>
<option value="admin"
@selected(request('role') === 'admin')>
Admin
</option>
<option value="editor"
@selected(request('role') === 'editor')>
Editor
</option>
<option value="user"
@selected(request('role') === 'user')>
User
</option>
</select>
<button type="submit">
Lọc
</button>
</form>
Trong Controller:
public function index()
{
$query = User::query();
// SEARCH
if ($search = request('search')) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
// FILTER ROLE
if ($role = request('role')) {
$query->where('role', $role);
}
// PAGINATION
$users = $query
->latest()
->paginate(10)
->withQueryString();
return view('users.index', compact('users'));
}Bây giờ có thể:
Search:
nguyen
Role:
adminURL:
/users?search=nguyen&role=adminDatabase sẽ tìm:
name/email chứa "nguyen"
AND
role = admin
Bây giờ chúng ta muốn:
Sắp xếp:
Mới nhất
Cũ nhất
Tên A → Z
Tên Z → ABlade:
<thead>
<tr>
<th>ID</th>
<th>
@if (request('sort') === 'name_asc')
<a href="{{ request()->fullUrlWithQuery(['sort' => 'name_desc']) }}">
Name ↓
</a>
@else
<a href="{{ request()->fullUrlWithQuery(['sort' => 'name_asc']) }}">
Name ↑
</a>
@endif
</th>
<th>Email</th>
<th>
@if (request('sort') === 'role_asc')
<a href="{{ request()->fullUrlWithQuery(['sort' => 'role_desc']) }}">
Role ↓
</a>
@else
<a href="{{ request()->fullUrlWithQuery(['sort' => 'role_asc']) }}">
Role ↑
</a>
@endif
</th>
</tr>
</thead>
Controller:
//SORT
switch (request('sort')) {
case 'name_asc':
$query->orderBy('name', 'asc');
break;
case 'name_desc':
$query->orderBy('name', 'desc');
break;
case 'role_asc':
$query->orderBy('role', 'asc');
break;
case 'role_desc':
$query->orderBy('role', 'desc');
break;
default:
$query->latest();
break;
}
Sau đó:
$users = $query
->paginate(10)
->withQueryString();
Đến đây chúng ta có:
Search
Filter
Sort
Pagination
Code hoàn chỉnh:
public function index()
{
$query = User::query();
/*
|--------------------------------------------------------------------------
| SEARCH
|--------------------------------------------------------------------------
*/
if ($search = request('search')) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
/*
|--------------------------------------------------------------------------
| FILTER ROLE
|--------------------------------------------------------------------------
*/
if ($role = request('role')) {
$query->where('role', $role);
}
/*
|--------------------------------------------------------------------------
| SORT
|--------------------------------------------------------------------------
*/
switch (request('sort')) {
case 'name_asc':
$query->orderBy('name', 'asc');
break;
case 'name_desc':
$query->orderBy('name', 'desc');
break;
case 'role_asc':
$query->orderBy('role', 'asc');
break;
case 'role_desc':
$query->orderBy('role', 'desc');
break;
default:
$query->latest();
break;
}
/*
|--------------------------------------------------------------------------
| PAGINATION
|--------------------------------------------------------------------------
*/
$users = $query
->paginate(10)
->withQueryString();
return view('users.index', compact('users'));
}Đây chính là cấu trúc mà chúng ta sẽ thường xuyên sử dụng trong các trang quản trị Laravel.
Blade:
<form
method="GET"
action="{{ route('users.index') }}"
class="flex gap-2 mb-4"
>
{{-- SEARCH --}}
<input
type="text"
name="search"
value="{{ request('search') }}"
placeholder="Tìm tên hoặc email..."
>
{{-- ROLE --}}
<select name="role">
<option value="">
Tất cả Role
</option>
<option
value="admin"
@selected(request('role') === 'admin')
>
Admin
</option>
<option
value="editor"
@selected(request('role') === 'editor')
>
Editor
</option>
<option
value="user"
@selected(request('role') === 'user')
>
User
</option>
</select>
{{-- SORT --}}
<select name="sort">
<option
value="latest"
@selected(request('sort', 'latest') === 'latest')
>
Mới nhất
</option>
<option
value="oldest"
@selected(request('sort') === 'oldest')
>
Cũ nhất
</option>
<option
value="name_asc"
@selected(request('sort') === 'name_asc')
>
Tên A → Z
</option>
<option
value="name_desc"
@selected(request('sort') === 'name_desc')
>
Tên Z → A
</option>
</select>
<button type="submit">
🔎 Tìm kiếm
</button>
</form>Kết quả:
┌──────────────────────────────────────────────────────────┐
│ 🔎 Nguyễn [Admin ▼] [Mới nhất ▼] [ Tìm kiếm ] │
└──────────────────────────────────────────────────────────┘
Nên có thêm:
<a href="{{ route('admin.users.index') }}">
Xóa bộ lọc
</a>Khi click:
/admin/userstoàn bộ:
search
role
sortđược xóa.
Nếu dự án đang sử dụng Tailwind:
{{ $users->links() }}Laravel có thể render pagination view tương ứng với frontend stack đã cấu hình.
Nếu cần tùy chỉnh giao diện, Laravel cho phép publish pagination views để chỉnh sửa.
Cách trên hoàn toàn đủ cho nhiều dự án:
Search
+
Filter
+
Sort
+
PaginationNhưng khi làm Admin Dashboard, người dùng thường muốn giao diện giống:
┌───────────────────────────────────────────────────────────┐
│ Users [+ Add User] │
├───────────────────────────────────────────────────────────┤
│ Search [____________] Role [____] │
├────┬──────────────┬────────────────┬─────────┬───────────┤
│ ID │ Name │ Email │ Role │ Action │
├────┼──────────────┼────────────────┼─────────┼───────────┤
│ 1 │ Nguyễn Văn A │ a@gmail.com │ Admin │ Edit │
│ 2 │ Trần Văn B │ b@gmail.com │ User │ Edit │
│ 3 │ Lê Văn C │ c@gmail.com │ Editor │ Edit │
└────┴──────────────┴────────────────┴─────────┴───────────┘
Showing 1 to 10 of 245 users
1 2 3 4 5 NextĐây là lúc DataTables trở nên hữu ích.
DataTables.net là thư viện JavaScript dùng để biến HTML <table> thành một bảng dữ liệu tương tác.
Nó cung cấp nhiều chức năng:
Search
Sort
Pagination
Ajax
Responsive
Column ordering
Filtering
Server-side processingĐặc biệt:
serverSide: truecho phép DataTables gửi yêu cầu lên server mỗi khi người dùng:
chuyển trang
tìm kiếm
sort
filter
DataTables sẽ gửi request tới server và server trả JSON về cho bảng. (DataTables Plus)
Có hai cách sử dụng.
MySQL
↓
Laravel
↓
10.000 records
↓
Browser
↓
DataTablesBrowser nhận toàn bộ:
10.000 recordssau đó DataTables xử lý.
Phù hợp với:
100
500
1.000bản ghi tùy trường hợp.
Browser
↓
DataTables
↓
Laravel
↓
MySQLBrowser chỉ nhận dữ liệu cần thiết cho trang hiện tại.
Ví dụ:
Database:
1.000.000 UsersNhưng trang hiện tại chỉ cần:
10 Usersthì server xử lý:
Search
Sort
Filter
Paginationrồi trả về 10 records.
Đây là cách phù hợp hơn với dữ liệu rất lớn. DataTables cũng mô tả server-side processing là cách để database thực hiện phần tìm kiếm, ordering và paging cho các tập dữ liệu lớn. (DataTables Plus)
Có thể sử dụng CDN.
Trong layout:
<link
rel="stylesheet"
href="https://cdn.datatables.net/2.3.4/css/dataTables.dataTables.min.css"
>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.datatables.net/2.3.4/js/dataTables.min.js"></script>Lưu ý: Khi triển khai dự án thực tế, nên kiểm tra phiên bản DataTables hiện tại và cố định version phù hợp thay vì phụ thuộc vào một URL không version.
Ghi chú: xem thêm mục 4 bài 27 CRUD Posts để biết cách cài Datatables trong x-app-layout.
Blade:
<table id="users-table" class="display w-full">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Created</th>
</tr>
</thead>
<tbody>
</tbody>
</table>Chúng ta không cần:
@foreachvì dữ liệu sẽ được DataTables lấy bằng AJAX.
JavaScript:
$(document).ready(function () {
$('#users-table').DataTable({
ajax: '{{ route('admin.users.index') }}',
columns: [
{
data: 'id'
},
{
data: 'name'
},
{
data: 'email'
},
{
data: 'role'
},
{
data: 'created_at',
render: function (data) {
if (!data) {
return '';
}
const date = new Date(data);
return date.toLocaleString('vi-VN', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
}
]
});
});DataTables sẽ gửi request:
/admin/usersvà mong server trả JSON.
Trong Controller:
public function index(Request $request)
{
if ($request->ajax()) {
$users = User::latest()->get();
return response()->json([
'data' => $users
]);
}
return view('admin.users.index');
}Khi AJAX request:
/admin/usersLaravel trả:
{
"data": [
{
"id": 1,
"name": "Nguyễn Văn A",
"email": "a@gmail.com",
"role": "admin"
},
{
"id": 2,
"name": "Trần Văn B",
"email": "b@gmail.com",
"role": "user"
}
]
}DataTables đọc:
data: 'id'
data: 'name'
data: 'email'và đưa dữ liệu vào bảng.
Đoạn này:
User::latest()->get();sẽ lấy toàn bộ User.
Nếu database có:
500.000 Usersthì:
500.000 Users
↓
Laravel
↓
JSON
↓
Browserlà điều không nên làm.
Đây chính là lúc chúng ta cần:
Server-side DataTables
JavaScript:
$('#users-table').DataTable({
processing: true,
serverSide: true,
ajax: '{{ route('admin.users.index') }}',
columns: [
{
data: 'id'
},
{
data: 'name'
},
{
data: 'email'
},
{
data: 'role'
},
{
data: 'created_at'
}
]
});Hai dòng quan trọng:
processing: true,
serverSide: true,DataTables sẽ gửi các thông tin cần thiết cho server để server thực hiện processing. (DataTables Plus)
Ví dụ người dùng đang ở:
Trang 3DataTables có thể gửi các thông tin liên quan đến:
start
length
search
order
columns
drawVí dụ ý tưởng:
start = 20
length = 10
search = nguyenCó nghĩa:
Bắt đầu từ record 20
Lấy 10 record
Search "nguyen"Server xử lý:
MySQL
↓
WHERE
ORDER BY
LIMIT
OFFSETrồi trả kết quả.
Ví dụ đơn giản:
public function index(Request $request)
{
if ($request->ajax()) {
$query = User::query();
/*
|--------------------------------------------------------------------------
| SEARCH
|--------------------------------------------------------------------------
*/
if ($request->search['value']) {
$search = $request->search['value'];
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
/*
|--------------------------------------------------------------------------
| TOTAL
|--------------------------------------------------------------------------
*/
$recordsTotal = User::count();
$recordsFiltered = $query->count();
/*
|--------------------------------------------------------------------------
| ORDER
|--------------------------------------------------------------------------
*/
$query->orderBy('id', 'desc');
/*
|--------------------------------------------------------------------------
| PAGINATION
|--------------------------------------------------------------------------
*/
$users = $query
->skip($request->start)
->take($request->length)
->get();
/*
|--------------------------------------------------------------------------
| RESPONSE
|--------------------------------------------------------------------------
*/
return response()->json([
'draw' => intval($request->draw),
'recordsTotal' => $recordsTotal,
'recordsFiltered' => $recordsFiltered,
'data' => $users,
]);
}
return view('admin.users.index');
}Đây là phiên bản tự xử lý protocol của DataTables, rất tốt để người học hiểu bản chất.
DataTables cần các thông tin chính:
{
"draw": 1,
"recordsTotal": 500,
"recordsFiltered": 25,
"data": []
}Ý nghĩa:
drawĐánh dấu request hiện tại.
recordsTotalTổng số User trước khi filter:
500recordsFilteredTổng số User sau filter:
25dataDữ liệu của trang hiện tại:
10 Users
Bây giờ người dùng click:
Name ↕DataTables gửi thông tin column/order.
Ví dụ:
order[0][column] = 1
order[0][dir] = ascÝ nghĩa:
Column 1
↓
ASCChúng ta có thể xử lý (chèn chỗ ORDER - nhớ xóa dòng cũ):
$columns = [
'id',
'name',
'email',
'role',
'created_at',
];
$orderColumn = $columns[$request->order[0]['column']] ?? 'id';
$orderDirection = $request->order[0]['dir'] ?? 'desc';
$query->orderBy(
$orderColumn,
$orderDirection
);
Không nên viết:
$query->orderBy(
$request->order[0]['column'],
$request->order[0]['dir']
);vì giá trị từ browser là input không đáng tin cậy.
Thay vào đó:
$columns = [
'id',
'name',
'email',
'role',
'created_at',
];sau đó map:
$orderColumn = $columns[$index] ?? 'id';Đây là một nguyên tắc rất quan trọng:
Không đưa trực tiếp tên column từ request của người dùng vào câu SQL nếu không kiểm soát whitelist.
Đến đây DataTables đã có:
┌──────────────┐
│ DataTables │
└──────┬───────┘
│
┌─────────┼─────────┐
↓ ↓ ↓
Search Sort Paging
│ │ │
└─────────┼─────────┘
↓
Laravel
↓
MySQLĐây là kiến trúc rất phổ biến trong Admin Dashboard.
Dùng:
paginate()khi:
Website đơn giản
Blog
Danh sách bài viết
Danh mục
Trang frontend
Không cần Ajax
Muốn SEO tốt
Muốn code đơn giản
Ví dụ:
$posts = Post::latest()->paginate(12);
DataTables rất phù hợp với:
Admin DashboardVí dụ:
Users
Posts
Orders
Products
Invoices
Customers
Transactions
LogsĐặc biệt khi cần:
Search
Sort
Filter
Pagination
Ajaxtrong cùng một bảng.
Khi dữ liệu lớn.
Ví dụ:
1.000 recordsCó thể client-side vẫn ổn tùy trường hợp.
Nhưng:
100.000
500.000
1.000.000+thì server-side processing thường hợp lý hơn.
DataTables cũng khuyến nghị server-side processing cho các tập dữ liệu rất lớn, vì database có thể thực hiện các tác vụ filtering, ordering và paging hiệu quả hơn phía browser. (DataTables Plus)
Không nên viết:
$users = User::all();rồi:
collect($users)
->filter(...)
->sort(...)
->paginate(...);Đối với dữ liệu lớn, đây là cách không tốt.
Vì:
MySQL
↓
Tất cả records
↓
PHP RAM
↓
Filter
↓
Sort
↓
PaginationThay vào đó:
MySQL
↓
WHERE
ORDER BY
LIMIT
↓
Laravel
↓
BrowserDatabase phải làm phần lớn công việc.
Thay vì:
$users = User::all();hãy hình thành thói quen:
$query = User::query();Sau đó:
if (...) {
$query->where(...);
}
if (...) {
$query->orderBy(...);
}
$users = $query->paginate(10);Đây là tư duy rất quan trọng khi xây dựng Admin CMS.
Khi Controller bắt đầu dài, chúng ta có thể đưa logic filter vào Model.
Ví dụ:
class User extends Authenticatable
{
public function scopeSearch($query, $search)
{
return $query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
}Controller:
$query = User::query();
if ($search = request('search')) {
$query->search($search);
}Code Controller trở nên sạch hơn.
Phần Scope sẽ được học sâu hơn ở:
Trong dự án lớn hơn:
Controller
↓
Service
↓
Repository / Query
↓
Model
↓
DatabaseVí dụ:
UserController
↓
UserService
↓
User Query
↓
MySQLNhưng ở giai đoạn hiện tại không nên làm quá phức tạp.
Người mới học Laravel chỉ cần nắm chắc:
User::query()→
where()→
orderBy()→
paginate()là đã rất quan trọng.
Search cũng là dữ liệu từ người dùng.
Laravel 13 cung cấp nhiều cách validation request, từ $request->validate() đến Form Request cho các trường hợp phức tạp hơn. (Laravel)
Ví dụ:
$request->validate([
'search' => ['nullable', 'string', 'max:100'],
'role' => ['nullable', 'in:admin,editor,user'],
]);Không nhất thiết mọi ô search đều phải validation phức tạp, nhưng đây là thói quen tốt.
Ví dụ:
public function index(Request $request)
{
$request->validate([
'search' => ['nullable', 'string', 'max:100'],
'role' => ['nullable', 'in:admin,editor,user'],
'sort' => [
'nullable',
'in:latest,oldest,name_asc,name_desc'
],
]);
$query = User::query();
// SEARCH
if ($search = $request->search) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
// FILTER
if ($request->role) {
$query->where('role', $request->role);
}
// SORT
match ($request->sort) {
'oldest' => $query->oldest(),
'name_asc' => $query->orderBy('name'),
'name_desc' => $query->orderByDesc('name'),
default => $query->latest(),
};
// PAGINATION
$users = $query
->paginate(10)
->withQueryString();
return view('users.index', compact('users'));
}Đây là phiên bản khá tốt cho một CRUD Admin cơ bản.
Sau bài này, chúng ta đã đi từ:
User::all();đến:
User::query()và xây dựng:
USERS
│
↓
User::query()
│
┌──────────┼──────────┐
↓ ↓ ↓
Search Filter Sort
│ │ │
└──────────┼──────────┘
↓
Pagination
│
↓
BladeHoặc với DataTables:
Browser
│
↓
DataTables
│
AJAX
↓
Laravel
│
↓
Eloquent
│
↓
MySQL
│
↓
JSON
│
↓
DataTables
Tạo trang:
/userscó:
Search
PaginationThêm:
Filter Rolevới:
All
Admin
Editor
UserThêm:
Sortgồm:
Mới nhất
Cũ nhất
Tên A → Z
Tên Z → AKết hợp:
Search
+
Filter
+
Sort
+
Paginationvà đảm bảo khi chuyển trang các query vẫn được giữ lại.
Cài:
DataTablesvà hiển thị User bằng AJAX.
Chuyển DataTables sang:
serverSide: truevà để Laravel xử lý:
Search
Sort
Paginationthay vì tải toàn bộ User về browser.
Người học đã có thể xây dựng một bảng Admin hoàn chỉnh:
┌─────────────────────────────────────────────────────────────┐
│ 👥 USERS [+ Thêm User] │
├─────────────────────────────────────────────────────────────┤
│ │
│ 🔎 [ Tìm tên/email ] │
│ │
│ Role: [ Tất cả ▼ ] Sort: [ Mới nhất ▼ ] │
│ │
├────┬────────────────┬──────────────────┬─────────┬─────────┤
│ ID │ Họ tên │ Email │ Role │ Action │
├────┼────────────────┼──────────────────┼─────────┼─────────┤
│ 1 │ Nguyễn Văn A │ a@gmail.com │ Admin │ ✏️ 🗑️ │
│ 2 │ Trần Văn B │ b@gmail.com │ User │ ✏️ 🗑️ │
│ 3 │ Lê Văn C │ c@gmail.com │ Editor │ ✏️ 🗑️ │
└────┴────────────────┴──────────────────┴─────────┴─────────┘
1 2 3 4 5 Next →Đây là bước chuyển rất quan trọng từ CRUD cơ bản sang Admin Dashboard thực tế.
Ghi nhớ công thức:
$query = Model::query();
$query->where(...);
$query->orderBy(...);
$query->paginate(...);Và khi dữ liệu lớn:
DataTables
+
AJAX
+
serverSide
+
Laravel
+
MySQLsẽ trở thành một kiến trúc rất hữu ích cho các bảng quản trị lớn. (DataTables Plus)
Bài tiếp theo — Bài 30: Soft Delete sẽ xây dựng hệ thống Thùng rác (Trash) với SoftDeletes, restore() và forceDelete(), sau đó áp dụng trực tiếp vào CRUD Users/Posts.
x1
quay về MỤC LỤC
Trong các bài trước, chúng ta đã xây dựng được các chức năng CRUD cho:CategoriesPostsUsersTuy nhiên, khi dữ liệu bắt đầu nhiều lên, một bảng kiểu:User...
Doanh nghiệp vừa và nhỏ sử dụng ngày càng nhiều nền tảng số, dữ liệu khách hàng, điện toán đám mây và trí tuệ nhân tạo, trong khi nguồn lực dành cho an ninh mạng còn hạn chế.
Người dân, du khách đến công viên Âu Lạc có thể truy cập wifi miễn phí, đồng thời tiếp cận dịch vụ công và thông tin chính thống của địa phương.
Hai con voi về "thủ phủ sầu riêng" Ea Knuếc làm lễ cầu no đủ, livetream bán hàng, trong khi địa phương chuẩn bị 10 container sầu riêng đạt chuẩn kết nối trực tiếp sang thị trường Trung Quốc.
Dù công nghệ số giúp việc mua bán, ký kết hợp đồng diễn ra nhanh hơn, song không ít doanh nghiệp vẫn băn khoăn rủi ro pháp lý, đặc biệt là các chứng cứ điện tử khi xảy ra tranh chấp.
OpenAI ra mắt phiên bản ChatGPT dành cho người 13-17 tuổi, tăng lớp bảo vệ và cho phụ huynh quản lý tài khoản nhưng không được xem nội dung trò chuyện.
🕒 Cập nhật: Thứ Sáu, ngày 21 tháng 08 năm 2026 lúc 17:28
Tuy có tăng trở lại trong kỳ điều hành mới nhất, các mặt hàng xăng dầu trong nước hiện vẫn đang duy trì mức giá r...
🕒 Cập nhật: Thứ Sáu, ngày 21 tháng 08 năm 2026 lúc 17:00
Cập nhật giá vàng hôm nay tối 21/8: Thị trường kim loại quý trong nước ghi nhận sức nóng khi giá vàng hôm nay đồn...
🕒 Cập nhật: Thứ Sáu, ngày 21 tháng 08 năm 2026 lúc 16:57
Trong tối ngày hôm nay 21/8, các mặt hàng xăng dầu trong nước vẫn đang áp dụng mức giá của kỳ điều hành gần nhất ...
🕒 Cập nhật: Thứ Sáu, ngày 21 tháng 08 năm 2026 lúc 14:26
Giá xăng dầu hôm nay tối 21/8 tăng đồng loạt ở tất cả các mặt hàng xăng và dầu sau điều chỉnh của Liên Bộ Công Th...
🕒 Cập nhật: Thứ Sáu, ngày 21 tháng 08 năm 2026 lúc 16:34
Thị trường kim loại quý chiều ngày 21/8 tiếp tục ghi nhận đà tăng trưởng sôi động ở cả thị trường trong nước lẫn ...