Ở bài trước chúng ta đã tạo model Project, Task và làm CRUD cơ bản. Tiếp tục với series, bài này mình sẽ giới thiệu với mọi người về query với Eloquent ORM trong Laravel 13: scope, soft delete, phân trang và collection, rồi thêm bộ lọc và phân trang cho danh sách task của TaskFlow.
1. Truy vấn với Eloquent.
Như đã thấy ở Bài 15, các method của Query Builder gọi nối tiếp được trên model, kết thúc bằng get(), first(), count()...
VD: Lấy các task chưa xong của project 1, hạn xa nhất lên trước (mình chạy trong php artisan tinker với dữ liệu mẫu).
$tasks = Task::where('project_id', 1)
->where('status', '!=', 'done')
->latest('due_date') // orderBy('due_date', 'desc')
->get();
$tasks::class; // Illuminate\Database\Eloquent\Collection
$tasks->pluck('title'); // ['Thiết kế trang chủ', 'Viết bài Seeder và Factory', 'Tối ưu ảnh trên trang']
Khác với DB::table(), kết quả là collection các object Task (mục 5). Khi bảng có hàng chục nghìn bản ghi, thay vì get() lấy hết vào bộ nhớ, các bạn dùng chunk() hoặc lazy() để lấy từng phần:
// mỗi lần query 200 task rồi gọi closure
Task::chunk(200, function (Collection $tasks) {
// xử lý 200 task
});
// lazy() cũng query từng phần nhưng trả về một LazyCollection để duyệt liên tục
foreach (Task::lazy() as $task) {
// ...
}
2. Query scope.
Local scope.
Điều kiện nào dùng nhiều lần, như lọc theo trạng thái, các bạn gom vào một local scope: một method protected trong model có attribute #[Scope], tham số đầu tiên là query:
- app/Models/Task.php
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
class Task extends Model
{
/**
* Chỉ lấy các task có trạng thái $status.
*/
#[Scope]
protected function status(Builder $query, string $status): void
{
$query->where('status', $status);
}
}
Khi gọi thì bỏ tham số $query, scope nối tiếp được với các method khác:
Task::status('todo')->pluck('id'); // [3, 4, 7, 10]
Task::status('doing')->where('project_id', 1)->count(); // 1
Task::status('todo')->orWhere->status('doing')->count(); // 7
Các bài viết Laravel cũ viết scope bằng method public function scopeStatus($query, $status), cách này vẫn chạy được trong Laravel 13.
Global scope.
Global scope tự thêm điều kiện vào mọi query của model. Các bạn tạo class bằng php artisan make:scope rồi gắn bằng attribute #[ScopedBy], hoặc viết nhanh bằng closure trong method booted():
protected static function booted(): void
{
// mọi query của Task chỉ lấy task chưa xong
static::addGlobalScope('chua-xong', function (Builder $query) {
$query->where('status', '!=', 'done');
});
}
// Task::count() là 7, Task::withoutGlobalScope('chua-xong')->count() là 10
Global scope dễ gây bất ngờ khi đọc code nên TaskFlow không dùng đoạn trên, nhưng các bạn sẽ gặp nó ngay ở soft delete.
3. Soft delete.
Soft delete (xóa mềm) không xóa bản ghi mà ghi thời điểm xóa vào cột deleted_at, nhờ vậy task xóa nhầm vẫn khôi phục được. Đầu tiên thêm cột bằng migration (Bài 12):
php artisan make:migration add_deleted_at_to_tasks_table --table=tasks
public function up(): void
{
Schema::table('tasks', function (Blueprint $table) {
$table->softDeletes(); // cột deleted_at nullable
});
}
public function down(): void
{
Schema::table('tasks', function (Blueprint $table) {
$table->dropSoftDeletes();
});
}
Chạy php artisan migrate, rồi thêm trait SoftDeletes vào model Task: use HasFactory, SoftDeletes; (Illuminate\Database\Eloquent\SoftDeletes). Lúc này:
Task::find(10)->delete(); // true
Task::count(); // 9
Task::withTrashed()->count(); // 10, tính cả task đã xóa
Task::onlyTrashed()->pluck('title'); // ['Lên lịch trình tham quan']
$task = Task::withTrashed()->find(10);
$task->trashed(); // true
$task->restore(); // true
Task::count(); // 10
// $task->forceDelete(); xóa hẳn khỏi database
Trait này chính là một global scope, thêm "tasks"."deleted_at" is null vào mọi query của Task, các bạn thấy được bằng Task::status('todo')->toRawSql().
Chú ý: DB::table('tasks') không đi qua model nên vẫn đếm cả task đã xóa. Trang thống kê của Bài 14 phải tự thêm điều kiện vào leftJoin:
- app/Http/Controllers/TaskStatsController.php
->leftJoin('tasks', fn ($join) => $join->on('tasks.project_id', '=', 'projects.id')->whereNull('tasks.deleted_at'))
4. Phân trang.
Thay get() bằng paginate($perPage), Laravel tự đọc số trang từ ?page= trên URL và thêm limit, offset vào câu query:
$page = Task::paginate(3);
$page->count(); // 3, số task của trang hiện tại
$page->total(); // 10
$page->lastPage(); // 4
$page->currentPage(); // 1
Task::simplePaginate(3)->hasMorePages(); // true
paginate() chạy thêm một câu count để biết tổng số trang. simplePaginate() bỏ câu đó, chỉ hiện được link "Trước", "Sau".
Lọc và phân trang task trong TaskFlow.
Trang /projects chỉ cần đổi Project::all() thành Project::paginate(5). Với tab task của trang chi tiết, mình lọc theo ?status= bằng scope vừa viết:
- app/Http/Controllers/ProjectController.php
public function show(Request $request, Project $project): View
{
$status = $request->query('status');
$tasks = Task::where('project_id', $project->id)
// chỉ lọc khi status hợp lệ, ?status=abc thì bỏ qua
->when(in_array($status, ['todo', 'doing', 'done'], true), fn ($query) => $query->status($status))
->paginate(3)
// giữ ?tab= và ?status= trong link phân trang
->withQueryString();
return view('projects.show', compact('project', 'tasks', 'status'))
->with('tab', $request->query('tab', 'overview'));
}
Trong đó:
when($condition, $callback)chỉ gọi$callbackkhi$conditionlàtrue, đỡ phải tách query ra để viếtif.withQueryString()đưa các tham số hiện có trên URL vào link phân trang, thiếu nó thì bấm sang trang 2 sẽ mất bộ lọc.
Trong view, paginator duyệt được bằng @forelse như collection, còn links() in ra các link phân trang đã có sẵn class Tailwind (file resources/css/app.css của Laravel 13 đã có @source tới view phân trang):
- resources/views/projects/show.blade.php
@foreach (['' => 'Tất cả', 'todo' => 'Todo', 'doing' => 'Doing', 'done' => 'Done'] as $value => $label)
<a href="{{ route('projects.show', ['project' => $project, 'tab' => 'tasks', 'status' => $value ?: null]) }}"
@class(['text-blue-600', 'font-bold' => (string) $status === $value])>{{ $label }}</a>
@endforeach
<ul class="mb-4 list-disc pl-6">
@forelse ($tasks as $task)
<li>{{ $task->title }}</li>
@empty
<li>Không có task nào.</li>
@endforelse
</ul>
{{ $tasks->links() }}
Tab tổng quan đổi count($tasks) thành $tasks->total(), chữ "Previous", "Showing... results" mình dịch trong lang/vi/pagination.php và lang/vi.json. Kết quả: /projects/1?tab=tasks hiện 3 task đầu, dòng "Hiển thị 1 đến 3 trong tổng số 4 kết quả" và link sang ?tab=tasks&page=2, còn ?tab=tasks&status=todo chỉ còn 2 task todo.
5. Collection.
get(), all() trả về Illuminate\Database\Eloquent\Collection, một lớp bọc mảng với hàng trăm method. So sánh cách lấy tên các task todo:
$tasks = Task::all();
// mảng thường
$titles = array_map(fn (Task $task) => $task->title, array_filter($tasks->all(), fn (Task $task) => $task->status === 'todo'));
// collection: gọi nối tiếp, dễ đọc hơn
$titles = $tasks->where('status', 'todo')->pluck('title');
Một số method hay dùng:
$tasks->map(fn (Task $task) => mb_strtoupper($task->title))->first(); // VIẾT BÀI SEEDER VÀ FACTORY
$tasks->filter(fn (Task $task) => $task->assignee_id === null)->pluck('id'); // [3, 4, 7, 10]
$tasks->groupBy('status')->map->count(); // ['doing' => 3, 'done' => 3, 'todo' => 4]
$tasks->sum('project_id'); // 19
$tasks->contains('status', 'doing'); // true
map->count() ở trên là higher-order message: viết tắt của map(fn ($group) => $group->count()). Tương tự, $tasks->filter->assignee_id->count() trả về 6 (số task đã có người làm).
Lưu ý: Method của collection chạy trên dữ liệu đã lấy về PHP. Task::where('status', 'todo')->count() đếm bằng SQL, còn Task::all()->where('status', 'todo')->count() lấy cả bảng rồi mới lọc, nên lọc được ở query thì đừng để collection làm.
6. Lời kết.
Như vậy chúng ta đã tìm hiểu xong query với Eloquent ORM trong Laravel 13: local scope, global scope, soft delete, phân trang và collection. Các bạn xem thêm tài liệu về Eloquent, phân trang và collection. Bài sau mình sẽ giới thiệu về Eloquent relationship với hasOne, hasMany, belongsTo, để thay các đoạn Task::where('project_id', ...) bằng quan hệ "project có nhiều task".
Đăng ký nhận tin.
Chúng tôi chỉ gửi tối đa 2 lần trên 1 tháng. Tuyên bố không spam mail!





0 Comments