Lập trình · 22/09/2026

Học Laravel 13 – Bài 13: Filter, Search, Sort và Pagination trong Laravel 13

Bài 13 — Filter, Search, Sort và Pagination: xây một truy vấn danh sách task có contract rõ ràng trên nền eager loading ở bài 12. Đây là lớp truy vấn chạy được trong test; chưa mở route đọc dữ liệu công khai trước khi có authentication và policy.

Filter, Search, Sort và Pagination trong Laravel 13

Bài 13 — Filter, Search, Sort và Pagination: xây một truy vấn danh sách task có contract rõ ràng trên nền eager loading ở bài 12. Đây là lớp truy vấn chạy được trong test; chưa mở route đọc dữ liệu công khai trước khi có authentication và policy.

1. Chốt contract của danh sách

Danh sách luôn thuộc một Project do phía server lựa chọn. Bộ lọc status nhận todo, doing hoặc done; q tìm title, tối đa 100 ký tự; sort chỉ nhận newest, oldest hoặc title. per_page từ 1 đến 50, mặc định 20; page từ 1 đến 10.000. Các giới hạn là lựa chọn của ví dụ TaskFlow, không phải giới hạn cứng của Laravel.

Không truyền nguyên chuỗi sort từ client vào orderByRaw(). Binding bảo vệ giá trị không có nghĩa tên cột và biểu thức SQL tùy ý trở nên an toàn. Một danh sách lựa chọn cố định vừa tránh truy vấn ngoài dự kiến vừa làm URL dễ hiểu.

2. Tạo lớp truy vấn

Tạo app/Queries/TaskListQuery.php:

<?php

namespace App\Queries;

use App\Models\Project;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;

class TaskListQuery
{
    public function paginate(Project $project, array $input): LengthAwarePaginator
    {
        $filters = Validator::make($input, [
            'status' => ['nullable', Rule::in(['todo', 'doing', 'done'])],
            'q' => ['nullable', 'string', 'max:100'],
            'sort' => ['sometimes', Rule::in(['newest', 'oldest', 'title'])],
            'per_page' => ['sometimes', 'integer', 'min:1', 'max:50'],
            'page' => ['sometimes', 'integer', 'min:1', 'max:10000'],
        ])->validate();

        $query = $project->tasks()->with('assignee:id,name');
        if (! empty($filters['status'])) {
            $query->where('status', $filters['status']);
        }
        if (isset($filters['q']) && trim($filters['q']) !== '') {
            // LIKE wildcards are intentionally supported in this course example.
            $query->where('title', 'like', '%'.trim($filters['q']).'%');
        }
        match ($filters['sort'] ?? 'newest') {
            'oldest' => $query->orderBy('id'),
            'title' => $query->orderBy('title')->orderBy('id'),
            default => $query->orderByDesc('id'),
        };

        return $query->paginate((int) ($filters['per_page'] ?? 20), ['*'], 'page', (int) ($filters['page'] ?? 1))
            ->appends(array_diff_key($filters, ['page' => true]));
    }
}

Lớp dùng Validator ở ranh giới nhận array để cả test và người gọi khác đều nhận cùng contract. Chưa đưa vào controller nên ValidationException hiện được kiểm tra trực tiếp; khi nối HTTP, phải thống nhất kiểu phản hồi theo form hoặc JSON như bài 7.

Truy vấn bắt đầu từ $project->tasks(), không phải Task::query() rồi hy vọng người gọi nhớ thêm where project_id. Tuy nhiên, Project vẫn cần được tìm trong phạm vi người dùng được phép truy cập. Scope dữ liệu không thay authorization.

3. Tìm kiếm LIKE không phải full-text

q được gắn vào một giá trị parameterized của where, không nối thành SQL thô. Nhưng % và _ vẫn là wildcard của LIKE. Ở contract này, q=% có thể khớp mọi title; test xác nhận hành vi đó. Nếu sản phẩm cần tìm đúng ký tự %, phải thiết kế escape theo database engine và bổ sung test, không chỉ đổi nhãn UI thành “tìm chính xác”.

LIKE với % ở đầu thường không tận dụng index B-tree như tìm prefix. Dấu tiếng Việt, hoa thường và collation có thể khác giữa SQLite, MySQL và PostgreSQL. Bài này không hứa tìm kiếm ngôn ngữ tự nhiên hoặc kết quả giống nhau trên mọi engine.

4. Thứ tự ổn định và giới hạn offset

Sort title có id làm khóa phụ để hai task cùng title vẫn có thứ tự xác định. newest/oldest dùng ID nên không cần khóa phụ khác. Điều này giải quyết hòa thứ tự, không tạo snapshot dữ liệu giữa hai lần tải trang: thêm/xóa record trong lúc phân trang offset vẫn có thể làm trùng hoặc bỏ sót.

paginate trả tổng số record và các trang; phép đếm tổng cũng có chi phí. Khi chỉ cần trước/sau, cân nhắc simplePaginate; với tập lớn và thứ tự phù hợp, cursor pagination là một phương án khác. Đổi chiến lược phải xét contract URL và trải nghiệm, không phải thay method rồi giữ mọi giả định cũ.

5. Giữ filter trên liên kết trang

appends chỉ nhận filter đã validate, bỏ page cũ để paginator tạo page mới. Không sao chép toàn bộ query string nếu nó chứa tham số ngoài contract hoặc secret. Khi nối view sau phần phân quyền, có thể render $tasks->links(); form lọc dùng GET để URL có thể chia sẻ, và reset về trang 1 khi đổi bộ lọc.

assignee được eager load chỉ id/name để tránh N+1 khi hiển thị người phụ trách. Task vẫn giữ assignee_id trong tập cột của nó. Chưa có assignee thì quan hệ null, UI phải có trạng thái chưa giao thay vì truy cập name trực tiếp.

6. Kiểm thử phạm vi, thứ tự và input sai

Tạo tests/Feature/TaskListQueryTest.php:

<?php

namespace Tests\Feature;

use App\Models\Project;
use App\Models\Task;
use App\Queries\TaskListQuery;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;

class TaskListQueryTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:',
            'database.connections.sqlite.url' => null]);
        DB::purge('sqlite');
        $this->artisan('migrate', ['--force' => true])->assertSuccessful();
    }

    public function test_filters_remain_project_scoped_and_order_is_stable(): void
    {
        $project = Project::factory()->create();
        $first = Task::factory()->for($project)->create(['title' => 'Review code']);
        $second = Task::factory()->for($project)->create(['title' => 'Review code']);
        Task::factory()->for($project)->done()->create(['title' => 'Review done']);
        Task::factory()->create(['title' => 'Review outsider']);

        $page = app(TaskListQuery::class)->paginate($project, [
            'status' => 'todo', 'q' => 'Review', 'sort' => 'title', 'per_page' => 1,
        ]);
        $this->assertSame(2, $page->total());
        $this->assertSame($first->id, $page->items()[0]->id);
        $this->assertTrue($page->items()[0]->relationLoaded('assignee'));
        $next = app(TaskListQuery::class)->paginate($project, [
            'status' => 'todo', 'q' => 'Review', 'sort' => 'title', 'per_page' => 1, 'page' => 2,
        ]);
        $this->assertSame($second->id, $next->items()[0]->id);
        $this->assertStringContainsString('status=todo', $page->nextPageUrl());
    }

    public function test_untrusted_sort_and_excessive_page_size_are_rejected(): void
    {
        $project = Project::factory()->create();
        try {
            app(TaskListQuery::class)->paginate($project, ['sort' => 'title desc; drop table tasks', 'per_page' => 1000]);
            $this->fail('Expected validation errors');
        } catch (ValidationException $exception) {
            $this->assertArrayHasKey('sort', $exception->errors());
            $this->assertArrayHasKey('per_page', $exception->errors());
        }
    }

    public function test_empty_results_and_wildcard_contract(): void
    {
        $project = Project::factory()->create();
        Task::factory()->for($project)->create(['title' => 'Write test']);
        $query = app(TaskListQuery::class);
        $this->assertSame(0, $query->paginate($project, ['q' => 'absent'])->total());
        $this->assertSame(1, $query->paginate($project, ['q' => '%'])->total());
    }
}
php artisan test --filter=TaskListQueryTest
php artisan test

Toàn suite đã pass 33 test, 112 assertions. Dữ liệu test có task ở project khác, task done không khớp filter và hai title giống nhau. Nhờ vậy việc test pass không chỉ chứng minh “có kết quả”, mà xác minh scope và tie-breaker. Test còn chặn sort lạ/per_page quá lớn, kiểm tra kết quả rỗng và wildcard.

7. Bài tập và kiểm tra trước khi nối giao diện

Thêm test page vượt số trang thực tế và q="0" để tránh lỗi kiểm tra truthy bỏ mất chuỗi hợp lệ. Thêm task không có assignee, kiểm tra relationLoaded vẫn đúng. Đo query trên đúng fixture thay vì khẳng định paginate luôn chỉ có hai query: count, truy vấn trang và eager load có thể tạo nhiều câu SQL.

Trước HTTP thật, bổ sung auth/policy, thông báo filter sai và giới hạn tần suất phù hợp. Không dùng giới hạn page=10.000 như bằng chứng chống mọi truy vấn tốn kém. Đọc Laravel pagination khi chọn cơ chế trang cho sản phẩm.

Điều hướng: Bài 12 · Lộ trình. Bài tiếp theo xử lý transaction và cạnh tranh cập nhật.

Thảo luận

Bình luận 0

Đăng nhập để bình luận

Bạn cần có tài khoản để tham gia thảo luận và trả lời độc giả khác.

Đăng nhậpĐăng ký

Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.