مقاله

Pagination و Filtering در API Laravel — query parameter استاندارد

Pagination و Filtering در API Laravel — query parameter استاندارد

paginate، per_page، sort، filter و Spatie Query Builder — لیست API مقیاس‌پذیر بدون query دستی در Controller.

endpoint GET /posts بدون pagination یعنی هر درخواست می‌تواند کل جدول را بخواند و یک JSON بسیار بزرگ برگرداند — با بزرگ‌شدن داده، پاسخ کند می‌شود یا به timeout می‌خورد. فیلتر و sort هم اگر فقط از یک فهرست مجاز (allowlist) پذیرفته نشوند، راه را برای query ناخواسته و نام ستون دلخواه کلاینت باز می‌کنند. این راهنما الگوی امن و قابل نگهداری برای endpointهای لیستی در Laravel 12 است.

خلاصه: برای لیست API همیشه صفحه‌بندی کنید و سقف per_page بگذارید؛ بین paginate()، simplePaginate() و cursorPaginate() بر اساس نیاز UI و ترتیب داده انتخاب کنید؛ پارامترهای sort و filter را validate کنید و فقط نام‌های مجاز را به ستون واقعی map کنید؛ و فیلترها را در لینک صفحهٔ بعد حفظ کنید. شکل‌دادن به خروجی هر آیتم موضوع API Resource در Laravel است و قالب پاسخ خطا در مدیریت خطا در API Laravel بررسی شده؛ اینجا فقط صفحه‌بندی، فیلتر و مرتب‌سازی را پوشش می‌دهیم.

چرا API باید صفحه‌بندی کند؟

  • حجم پاسخ قابل پیش‌بینی: کلاینت موبایل یا فرانت هر بار تعداد مشخصی آیتم می‌گیرد، نه همهٔ رکوردها.
  • بار کنترل‌شده روی دیتابیس: query با limit اجرا می‌شود و eager load فقط برای همان صفحه انجام می‌شود.
  • قرارداد روشن: کلاینت از روی links و meta می‌فهمد صفحهٔ بعد وجود دارد یا نه.

Pagination پایه

$perPage = min(max($request->integer('per_page', 15), 1), 100);

$posts = Post::query()
    ->published()
    ->with(['category'])
    ->latest('published_at')
    ->paginate($perPage);

return PostResource::collection($posts);

سقف per_page حتماً — کاربر نتواند per_page=10000 بزند. حد پایین هم لازم است تا مقدار صفر یا منفی به query نرسد. with() برای جلوگیری از N+1 در همان صفحه است — جزئیات در Eager Loading و مشکل N+1.

paginate، simplePaginate یا cursorPaginate؟

هر سه روی Query Builder و Eloquent در Laravel 12 در دسترس‌اند. امضای آن‌ها:

paginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null, $total = null)
simplePaginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null)
cursorPaginate($perPage = null, $columns = ['*'], $cursorName = 'cursor', $cursor = null)
روشخروجینحوهٔ کارمناسب برای
paginate()LengthAwarePaginatorیک query شمارش (count) + query با offset؛ total و last_page داردUI با شمارهٔ صفحه و «نمایش ۱ تا ۱۵ از ۳۲۰»
simplePaginate()Paginatorبدون query شمارش؛ فقط می‌داند صفحهٔ بعد هست یا نهدکمه‌های «بعدی/قبلی» وقتی تعداد کل لازم نیست
cursorPaginate()CursorPaginatorبه‌جای offset، با شرط where روی مقدار ستون‌های مرتب‌سازی جلو می‌رودfeed و infinite scroll با ترتیب پایدار

وقتی paginator را به PostResource::collection() می‌دهید، Laravel کلیدهای data، links و meta را می‌سازد. در paginate() داخل meta مقادیری مثل current_page، per_page، total و last_page هست؛ در cursorPaginate() به‌جای شماره صفحه، next_cursor و prev_cursor برمی‌گردد. کلاینت باید با همین قرارداد کار کند، نه با حدس‌زدن شمارهٔ صفحه.

offset یا cursor — کدام را انتخاب کنیم؟

  • offset (paginate/simplePaginate): پرش مستقیم به صفحهٔ ۷ ممکن است و پیاده‌سازی ساده است. اما اگر بین دو درخواست رکورد جدید اضافه یا حذف شود، ممکن است یک آیتم دو بار دیده شود یا جا بیفتد. در offsetهای خیلی بزرگ هم دیتابیس معمولاً باید ردیف‌های قبلی را رد کند.
  • cursor: برای لیست‌هایی که پشت‌سرهم جلو می‌روند (feed، لاگ، پیام‌ها) پایدارتر است، چون از آخرین مقدار دیده‌شده ادامه می‌دهد. اما شمارهٔ صفحه و پرش به صفحهٔ دلخواه ندارد.

cursor همهٔ مشکلات دادهٔ بزرگ را حل نمی‌کند: اگر فیلتر روی ستون بدون index باشد یا کلاینت تعداد کل را لازم داشته باشد، مشکل سر جایش است. انتخاب را از نیاز UI شروع کنید، نه از ترند.

Query parameters قرارداد

  • page — شماره صفحه (برای paginate و simplePaginate)
  • cursor — مقدار opaque که خود API در لینک بعدی برمی‌گرداند؛ کلاینت آن را نمی‌سازد
  • per_page — تعداد (با حداقل و حداکثر)
  • sort — مثلاً -published_at (منفی = desc)
  • filter[category]، filter[tag] — فیلترهای مجاز
  • search — جستجوی متنی

این قرارداد را مستند کنید و در همهٔ endpointهای لیستی یکسان نگه دارید؛ کلاینت نباید برای هر endpoint نام پارامتر متفاوتی یاد بگیرد.

اعتبارسنجی و allowlist پارامترها

پارامترهای query هم ورودی کاربرند و باید validate شوند — دقیقاً مثل body درخواست:

use Illuminate\Validation\Rule;

$validated = $request->validate([
    'page' => ['sometimes', 'integer', 'min:1'],
    'per_page' => ['sometimes', 'integer', 'between:1,100'],
    'sort' => ['sometimes', 'string', Rule::in(['published_at', '-published_at', 'title', '-title'])],
    'filter' => ['sometimes', 'array:category,tag'],
    'filter.category' => ['sometimes', 'string', 'max:100'],
    'filter.tag' => ['sometimes', 'string', 'max:100'],
    'search' => ['sometimes', 'nullable', 'string', 'max:100'],
]);

قانون array:category,tag یعنی فقط همین کلیدها داخل filter مجازند و کلید ناشناخته خطای 422 می‌دهد. می‌توانید به‌جای خطا، پارامتر ناشناخته را نادیده بگیرید؛ مهم این است که رفتار مستند و یکسان باشد. برای endpointهای بزرگ‌تر این قوانین را به Form Request منتقل کنید — Form Request پیشرفته.

Sort امن

$sort = $request->string('sort', '-published_at')->toString();
$direction = str_starts_with($sort, '-') ? 'desc' : 'asc';
$key = ltrim($sort, '-');

// نام عمومی در API => ستون واقعی در دیتابیس
$sortable = [
    'published_at' => 'published_at',
    'title' => 'title',
];

$column = $sortable[$key] ?? 'published_at';

$query->orderBy($column, $direction)->orderBy('id', $direction);

هرگز sort را مستقیم از user به query ندهید. PDO نام ستون را bind نمی‌کند؛ مستندات Laravel هم صریحاً می‌گوید ورودی کاربر نباید نام ستون — از جمله ستون order by — را تعیین کند. map کردن نام عمومی به ستون واقعی یک مزیت دیگر هم دارد: اگر ستون دیتابیس را rename کنید، قرارداد API عوض نمی‌شود. مرتب‌سازی دوم روی id ترتیب رکوردهای با تاریخ یکسان را ثابت می‌کند تا آیتمی بین صفحه‌ها جابه‌جا نشود.

Filter ساده

$query
    ->when($request->filled('filter.category'), fn ($q) => $q->whereHas(
        'category',
        fn ($c) => $c->where('slug', $request->string('filter.category')->toString())
    ))
    ->when($request->filled('filter.tag'), fn ($q) => $q->whereHas(
        'tags',
        fn ($t) => $t->where('slug', $request->string('filter.tag')->toString())
    ));

filled() رشتهٔ خالی را «پر» حساب نمی‌کند؛ پس ?filter[category]= فیلتری اعمال نمی‌کند و همهٔ نتایج را برمی‌گرداند. اگر منطق فیلتر در چند endpoint تکرار می‌شود، آن را به local scope ببرید — Scope در Eloquent.

جستجوی متنی و فیلتر خالی

if ($request->filled('search')) {
    $term = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($request->string('search')->toString()));

    $query->where(fn ($q) => $q
        ->where('title', 'like', "%{$term}%")
        ->orWhere('excerpt', 'like', "%{$term}%"));
}

مقدار با binding به query می‌رود، اما کاراکترهای % و _ در LIKE معنی wildcard دارند؛ escape کردنشان باعث می‌شود جستجوی «۵۰%» دقیقاً همان را پیدا کند. LIKE با % در ابتدا معمولاً از index معمولی استفاده نمی‌کند؛ برای جستجوی جدی فارسی یک موتور جستجوی جدا (مثلاً Meilisearch از طریق Laravel Scout — پکیج اختیاری) را بسنجید — بهینه‌سازی query و cache.

ساختار پاسخ یکدست و حفظ فیلتر بین صفحه‌ها

$posts = $query->paginate($perPage)
    ->appends($request->only(['per_page', 'sort', 'filter', 'search']));

return PostResource::collection($posts);

بدون appends()، لینک next فقط ?page=2 است و کلاینتی که آن را دنبال کند فیلتر و sort را از دست می‌دهد. withQueryString() هم کل query string فعلی را اضافه می‌کند؛ ولی appends() با کلیدهای مشخص فقط پارامترهای شناخته‌شده را در لینک‌ها تکرار می‌کند. هر دو متد روی cursor paginator هم وجود دارند.

  • صفحهٔ خالی یا فیلتر بدون نتیجه: 200 با "data": [] — نه 404.
  • همهٔ endpointهای لیستی یک ساختار data/links/meta داشته باشند؛ اگر لازم است شکل meta را عوض کنید، این کار در لایهٔ Resource انجام می‌شود.
  • پارامتر نامعتبر (مثلاً per_page=abc) با همان قالب 422 همیشگی API جواب بگیرد.

Spatie Laravel Query Builder (پکیج اختیاری)

این پکیج جزو Laravel نیست و باید جدا نصب شود (composer require spatie/laravel-query-builder). اگر endpointهای لیستی زیادی دارید، allowlist فیلتر، sort و include را یک‌جا تعریف می‌کند:

use Spatie\QueryBuilder\AllowedFilter;
use Spatie\QueryBuilder\QueryBuilder;

$posts = QueryBuilder::for(Post::class)
    ->allowedFilters(['title', 'status', AllowedFilter::exact('category.slug')])
    ->allowedSorts(['published_at', 'title'])
    ->allowedIncludes(['category', 'user'])
    ->defaultSort('-published_at')
    ->paginate($perPage)
    ->appends(request()->query());

whitelist built-in است و برای پارامتر ناشناخته خطا برمی‌گرداند. برای یکی دو endpoint، همان کد دستی بالا با validation کافی است و وابستگی اضافه نمی‌خواهد.

Cursor pagination

$posts = Post::query()
    ->published()
    ->orderByDesc('published_at')
    ->orderByDesc('id')
    ->cursorPaginate(20);

محدودیت‌هایی که مستندات Laravel 12 ذکر می‌کند:

  • فقط لینک «بعدی/قبلی» دارد و لینک با شمارهٔ صفحه نمی‌سازد.
  • ترتیب باید بر اساس حداقل یک ستون یکتا یا ترکیبی یکتا از ستون‌ها باشد — به همین دلیل id بعد از published_at آمده.
  • ستون‌های دارای مقدار null در ترتیب پشتیبانی نمی‌شوند.
  • عبارت (expression) در order by فقط وقتی پشتیبانی می‌شود که alias شده و در select هم آمده باشد؛ expression پارامتردار پشتیبانی نمی‌شود.

یعنی sort دلخواه کاربر با cursor همیشه سازگار نیست؛ اگر چند sort مجاز دارید، هرکدام باید با ستون یکتای کمکی ترتیب کامل داشته باشد.

Index دیتابیس

ستون‌هایی که مدام فیلتر و sort می‌شوند باید index مناسب داشته باشند. index ترکیبی مثل (status, published_at) برای الگوی «where status = ? order by published_at» طراحی می‌شود؛ ترتیب ستون‌ها در index با شرط‌ها و ترتیب query مهم است. با EXPLAIN روی همان دیتابیس و حجم داده‌ای شبیه production بررسی کنید که index واقعاً استفاده می‌شود، و برای هر فیلتر جدید در allowlist، اثرش روی query را هم ببینید. index بیشتر هم بی‌هزینه نیست و نوشتن را سنگین‌تر می‌کند.

تست endpoint لیستی

چند تست کوچک قرارداد را حفظ می‌کند (مدل و factory مثال فرضی‌اند):

public function test_unknown_sort_is_rejected(): void
{
    $this->getJson('/api/posts?sort=password')->assertUnprocessable();
}

public function test_per_page_is_capped_and_filters_survive_pagination(): void
{
    Post::factory()->count(30)->published()->create();

    $response = $this->getJson('/api/posts?per_page=10&sort=-published_at')
        ->assertOk()
        ->assertJsonCount(10, 'data')
        ->assertJsonPath('meta.per_page', 10);

    $this->assertStringContainsString('sort=-published_at', urldecode($response->json('links.next')));
}

تست صفحهٔ خالی (200 با آرایهٔ خالی) و per_page بیش از حد را هم اضافه کنید. الگوی کلی تست HTTP در Feature Test در Laravel آمده.

اشتباهات رایج

  • برگرداندن Post::all() در endpoint لیستی
  • نبودن سقف و حداقل برای per_page
  • دادن مستقیم sort یا نام فیلتر از query string به orderBy یا where
  • گم‌شدن فیلترها در لینک صفحهٔ بعد
  • ترتیب غیریکتا (فقط published_at) که باعث تکرار یا جاافتادن آیتم بین صفحه‌ها می‌شود
  • 404 برای فیلتر بدون نتیجه به‌جای 200 با لیست خالی
  • استفاده از cursor pagination برای UI که شمارهٔ صفحه و تعداد کل لازم دارد

جمع‌بندی

همیشه صفحه‌بندی با سقف per_page، انتخاب آگاهانه بین offset و cursor، validate و allowlist برای sort و filter، و حفظ پارامترها در لینک‌ها. شکل خروجی هر آیتم را با API Resource بسازید، خطاهای 422 را با قالب مدیریت خطای API یکدست کنید و برای مسیر کامل ساخت API به ساخت REST API با Laravel 12 سر بزنید.

API لیستی سنگین

اشتراک‌گذاری