[Hướng dẫn Thực tiễn] Laravel: Tìm kiếm, Lọc, Sắp xếp & Phân trang Hoàn chỉnh – Thiết kế và Triển khai Trang Danh mục Dễ Tiếp cận
Bạn sẽ học được gì trong bài viết này (Tóm tắt chính trước tiên)
- Các mẫu triển khai an toàn cho tìm kiếm, lọc và sắp xếp bằng tham số GET
- Cách xây dựng logic tìm kiếm dễ bảo trì với
FormRequest
và một lớp bao bọc Query Builder (Filter
class) - Sự khác nhau và cách sử dụng tốt nhất của
paginate
,simplePaginate
, vàcursorPaginate
để cân bằng hiệu năng và trải nghiệm người dùng - Mẹo để giữ điều kiện lọc liên tục qua các trang bằng
links()->withQueryString()
- Triển khai để làm giao diện danh sách thân thiện với người khuyết tật (ví dụ:
fieldset/legend
,aria-live
,aria-current
,rel="next/prev"
, skip links, v.v.) - Thiết kế trải nghiệm nhẹ nhàng và phản hồi dễ tiếp cận cho tình huống “không tìm thấy kết quả”
- Mẫu code cho bố cục bảng/thẻ và quan điểm kiểm thử E2E
Ai nên đọc? (Đối tượng mục tiêu)
- Lập trình viên Laravel trình độ trung cấp: Nếu logic view index của bạn đang dần rối rắm
- Tech lead trong dự án khách hàng/SaaS: Muốn đặt ra tiêu chuẩn nhóm cho thiết kế bộ lọc an toàn và UI dễ tiếp cận
- Nhà thiết kế/QA: Muốn kết nối giữa quản lý trạng thái UI, hỗ trợ bàn phím, và hành vi trình đọc màn hình với triển khai thực tế
- CS/Chủ sản phẩm: Tìm cách giảm ticket hỗ trợ nhờ phản hồi rõ ràng khi “không có kết quả” hoặc tóm tắt số lượng kết quả
Mức độ dễ tiếp cận: ★★★★☆
Sử dụng nhóm
fieldset/legend
,aria-live
để thông báo thay đổi kết quả, phân trang cóaria-current
, thuộc tínhrel
, skip links, và trạng thái không phụ thuộc màu sắc. Kiểm thử trên trình đọc màn hình và thiết bị chữ nổi không nằm trong phạm vi, do đó chỉ đạt 4 sao.
1. Giới thiệu: Trang danh mục là tính năng được sử dụng nhiều nhất
Trong các ứng dụng doanh nghiệp và trang thương mại điện tử, liệt kê, tìm kiếm, lọc, và phân trang là những UI được sử dụng nhiều nhất.
Nếu người dùng bị lạc ở đây, nó sẽ trở thành nguồn gốc của sự khó chịu, sai sót và bỏ cuộc.
Laravel cung cấp Eloquent, Query Builder, FormRequest
, và bộ công cụ Paginator, nên chỉ với cấu hình tối thiểu, bạn có thể tạo ra code tìm kiếm sạch, dễ mở rộng và UI dễ tiếp cận.
Bài viết này giới thiệu một cấu trúc bạn có thể áp dụng trực tiếp trong dự án thực tế, kèm theo mẫu code.
2. Thiết kế thông tin: Giữ bộ lọc tối giản & nhóm có ý nghĩa
Bắt đầu bằng cách kiểm tra các trường lọc.
- Tập trung vào các bộ lọc thiết yếu và được dùng thường xuyên, như từ khóa (tìm kiếm văn bản toàn cục), danh mục (chọn đơn/nhiều), trạng thái (nháp/đã xuất bản), khoảng thời gian và thứ tự sắp xếp (ví dụ theo ngày tạo).
- Nhóm các bộ lọc theo ý nghĩa bằng
fieldset/legend
để hỗ trợ người dùng trình đọc màn hình. - Sử dụng phương thức GET để bộ lọc hiển thị trong URL, hỗ trợ chia sẻ và lưu bookmark.
Mục tiêu: Đưa người dùng đến dữ liệu mong muốn với ít bước nhất, đồng thời duy trì cấu trúc UI rõ ràng cho công nghệ hỗ trợ.
3. Định tuyến và Cấu trúc (Controller, Request, Filter)
app/
├─ Http/
│ ├─ Controllers/
│ │ └─ PostIndexController.php
│ ├─ Requests/
│ │ └─ PostIndexRequest.php // Xác thực đầu vào tìm kiếm
│ └─ Filters/
│ └─ PostFilters.php // Kết hợp các điều kiện truy vấn
resources/
└─ views/
└─ posts/
└─ index.blade.php // View index với form tìm kiếm
3.1 Route
// routes/web.php
use App\Http\Controllers\PostIndexController;
Route::get('/posts', PostIndexController::class)->name('posts.index');
3.2 Request: Danh sách trắng tham số query
// app/Http/Requests/PostIndexRequest.php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class PostIndexRequest extends FormRequest
{
public function authorize(): bool { return true; }
public function rules(): array
{
return [
'q' => ['nullable','string','max:100'],
'category' => ['nullable','array'],
'category.*'=> ['integer','min:1'],
'status' => ['nullable', Rule::in(['draft','published'])],
'from' => ['nullable','date'],
'to' => ['nullable','date','after_or_equal:from'],
'sort' => ['nullable', Rule::in(['created_desc','created_asc','title_asc'])],
'per_page' => ['nullable','integer','min:5','max:100'],
'page' => ['nullable','integer','min:1'],
];
}
protected function prepareForValidation(): void
{
$this->merge([
'q' => is_string($this->q) ? trim($this->q) : $this->q,
]);
}
public function attributes(): array
{
return [
'q' => 'Từ khóa',
'category' => 'Danh mục',
'status' => 'Trạng thái',
'from' => 'Ngày bắt đầu',
'to' => 'Ngày kết thúc',
'per_page' => 'Số mục trên mỗi trang',
];
}
}
3.3 Lớp Filter: Xây dựng truy vấn dựa trên input
// app/Http/Filters/PostFilters.php
namespace App\Http\Filters;
use Illuminate\Database\Eloquent\Builder;
class PostFilters
{
public function apply(Builder $query, array $input): Builder
{
if (!empty($input['q'])) {
$q = $input['q'];
$query->where(function($w) use ($q) {
$w->where('title','like',"%{$q}%")
->orWhere('body','like',"%{$q}%");
});
}
if (!empty($input['category'])) {
$cats = array_filter($input['category'], 'intval');
if ($cats) {
$query->whereHas('categories', fn($q) => $q->whereIn('categories.id', $cats));
}
}
if (!empty($input['status'])) {
$query->where('status', $input['status']);
}
if (!empty($input['from'])) { $query->whereDate('created_at','>=',$input['from']); }
if (!empty($input['to'])) { $query->whereDate('created_at','<=',$input['to']); }
$sort = $input['sort'] ?? 'created_desc';
match ($sort) {
'created_asc' => $query->orderBy('created_at','asc'),
'title_asc' => $query->orderBy('title','asc'),
default => $query->orderBy('created_at','desc'),
};
return $query;
}
}
### 3.4 Controller: Sử dụng `withQueryString()` và các kiểu paginator
```php
// app/Http/Controllers/PostIndexController.php
namespace App\Http\Controllers;
use App\Http\Requests\PostIndexRequest;
use App\Http\Filters\PostFilters;
use App\Models\Post;
class PostIndexController
{
public function __invoke(PostIndexRequest $request, PostFilters $filters)
{
$perPage = $request->integer('per_page') ?: 20;
$query = Post::query()->with(['author','categories']);
$query = $filters->apply($query, $request->validated());
$paginator = $query->paginate($perPage)->withQueryString();
$resultText = $this->resultSummaryText($paginator->total(), $paginator->currentPage(), $paginator->lastPage());
return view('posts.index', [
'paginator' => $paginator,
'input' => $request->validated(),
'resultText'=> $resultText,
]);
}
private function resultSummaryText(int $total, int $page, int $last): string
{
if ($total === 0) return 'Không tìm thấy kết quả phù hợp. Vui lòng điều chỉnh bộ lọc của bạn.';
return "Có tổng cộng {$total} kết quả. Bạn đang xem trang {$page} trên tổng số {$last}.";
}
}
Tóm tắt các loại Phân trang
paginate()
– dùng khi cần tổng số kết quả và số trangsimplePaginate()
– phiên bản nhẹ hơn nếu không cần tổng sốcursorPaginate()
– cho tập dữ liệu lớn với thứ tự ổn định (ví dụ: theo created_at + ID)
Bản dịch sẽ tiếp tục cho các phần còn lại: từ 4 đến 15, theo cùng cấu trúc…