When you're fetching thousands of records from a database to display in a table or listing, pagination becomes essential—not just for user experience, but for server performance and database efficiency. Laravel pagination: a PHP & Laravel guide for developers explores three distinct pagination strategies that let you control memory usage, query performance, and API response times. Let's walk through when and how to use each one, and how to surface pagination metadata to your frontend consumers.
Understanding pagination in Laravel
Pagination divides large result sets into manageable chunks, typically delivered one page at a time. Laravel abstracts this complexity into elegant methods that handle query building, URL generation, and view rendering. The framework offers three main approaches—each with different trade-offs around database queries, memory, and feature completeness.
The choice between them depends on your dataset size, how frequently it changes, and whether you need total counts. We'll examine real scenarios where each shines.
paginate() with total counts
The standard paginate() method is your go-to for most applications. It executes two queries: one to count total records, another to fetch the current page's data. That count is essential for rendering "Page 2 of 47" or a total record indicator.
// In a controller
$users = User::where('status', 'active')
->paginate(15);
// Result: Eloquent paginator with links, path, and total
return view('users.index', ['users' => $users]);The paginator object includes:
items()— the page's recordstotal()— total record count across all pagescurrentPage()— current page numberlastPage()— final page numberhasMorePages()— boolean for conditional renderinglinks()— HTML pagination links
In a Blade template, render pagination links with:
<div>
@foreach($users as $user)
<div>{{ $user->name }}</div>
@endforeach
</div>
{{ $users->links() }}You can customize the view with links('pagination.custom') to match your design system. Laravel ships with Bootstrap and Tailwind pagination views, or build your own by publishing the pagination views via php artisan vendor:publish.
The trade-off: when your table has millions of rows, counting all records becomes a slow operation. On PostgreSQL or MySQL, COUNT(*) locks tables momentarily and scans the entire index. For frequently accessed, large tables, this overhead accumulates.
simplePaginate() skips the count query
If you don't need the total count or final page number, use simplePaginate(). It runs only one query—the one fetching your page's records—and returns a simpler paginator. You gain speed; you lose context about how many pages exist in total.
// Fetch 15 records per page, no count query
$users = User::where('status', 'active')
->simplePaginate(15);
// $users->total() is not available
// $users->lastPage() is not available
// But $users->hasMorePages() worksThis is ideal for:
- Infinite scroll UI patterns where you only show a "Load more" button
- High-traffic endpoints where every millisecond counts
- Tables so large that exact counts are rarely relevant to the user
- APIs serving mobile clients with limited bandwidth
In a view, you'd render simpler pagination:
@if($users->hasMorePages())
<a href="{{ $users->nextPageUrl() }}">Load more</a>
@endifThe downside: users never see a record count or know roughly how many pages remain. Some applications can live without that information; others can't.
cursorPaginate() for very large tables
When you're dealing with truly massive tables—millions of rows appended daily, like logs or events—offset-based pagination breaks down. Offset requires the database to scan and skip millions of rows before returning results. Page 500 on a 10-million-row table demands 500 × page_size disk seeks.
Cursor-based pagination solves this by using a marker (typically a timestamp or ID) instead of an offset. It's constant-time, regardless of dataset size.
// Cursor pagination by ID
$logs = Log::where('level', 'error')
->orderBy('id', 'desc')
->cursorPaginate(50);
// In the view or API response
{{ $logs->links() }}
// Generates: ?cursor=eyJpZCI6IDEyMzQ1LCAiX3BvaW50c1RvV2FyZHMiOiAiYiJ9Cursor pagination works best with:
- Time-series data (logs, events, transactions)
- Append-only tables where old rows don't change
- Real-time feeds or activity streams
- REST or GraphQL APIs serving high-volume traffic
The cursor is encoded and opaque to the client; it encapsulates the ordering state. When a client requests the next page, they pass the cursor back, and Laravel decodes it to fetch the next batch.
// Client sends: GET /logs?cursor=eyJpZCI6IDEyMzQ1LCAiX3BvaW50c1RvV2FyZHMiOiAiYiJ9
// Laravel retrieves records *after* that cursor
$logs = Log::where('level', 'error')
->orderBy('id', 'desc')
->cursorPaginate(50);Caveat: cursor pagination doesn't work with offset queries. You can't jump to page 500 directly; you must traverse forward (or backward). This is a feature, not a bug—it prevents the performance cliff that offset introduces.
Return pagination meta in API Resources
When building REST APIs, your frontend needs pagination metadata: current page, total pages, whether more data exists. Laravel API Resources wrap your models and add structure. Combine them with paginated results to return consistent, predictable JSON.
// app/Http/Resources/UserResource.php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
];
}
}In your controller, paginate and wrap the results:
public function index(Request $request)
{
$users = User::where('status', 'active')
->paginate($request->input('per_page', 15));
return UserResource::collection($users);
}Laravel automatically includes pagination metadata in the JSON response:
{
"data": [
{ "id": 1, "name": "Alice", "email": "alice@example.com" },
{ "id": 2, "name": "Bob", "email": "bob@example.com" }
],
"links": {
"first": "http://example.com/api/users?page=1",
"last": "http://example.com/api/users?page=47",
"prev": "http://example.com/api/users?page=1",
"next": "http://example.com/api/users?page=3"
},
"meta": {
"current_page": 2,
"from": 16,
"last_page": 47,
"per_page": 15,
"to": 30,
"total": 700
}
}Your frontend can now render pagination controls without additional requests. The structure is predictable across endpoints, reducing client-side logic.
For APIs using simplePaginate(), the response omits total and last_page:
public function index(Request $request)
{
$users = User::where('status', 'active')
->simplePaginate($request->input('per_page', 15));
return UserResource::collection($users);
// meta now contains: current_page, from, per_page, to, path
// No total, last_page
}Common mistakes
- Using paginate() on millions of rows without an index. Always ensure your WHERE clauses and ORDER BY columns are indexed. A COUNT(*) on an unindexed, multi-million-row table will lock your database for seconds.
- Ignoring per_page user input. If you allow
per_pagefrom the request, validate it to prevent a user from requesting 10,000 records at once:$per_page = min($request->input('per_page', 15), 100); - Forgetting to order by in cursor pagination. Cursor pagination relies on consistent ordering. If you forget
orderBy(), results are unpredictable and clients will miss or duplicate records. - Mixing offset and cursor pagination in the same API endpoint. Pick one strategy and stick to it. Returning cursor links when the client expects offset will break integrations.
- Not caching expensive count queries. For tables where exact totals matter but counts are expensive, consider caching
DB::table('users')->count()and invalidating on create/delete events, rather than running COUNT on every request.
Key takeaways
- Use
paginate()for typical CRUD interfaces where users expect total counts and can jump between pages. It's the safest default. - Reach for
simplePaginate()when you need speed on high-traffic endpoints and a "Load more" UX is acceptable. You skip the count query and gain performance. - Deploy
cursorPaginate()for massive, append-only datasets—logs, events, feeds—where offset performance degrades unacceptably. It's constant-time regardless of position. - Always include pagination metadata in API responses using Laravel Resources. Clients need
current_page,last_page, andtotalto render controls correctly. - Index your database columns. Pagination performance hinges on fast WHERE and ORDER BY execution. A missing index turns pagination into a bottleneck.
- Validate and cap user-supplied
per_pagevalues. Never let a client request unlimited records in one call. - Test pagination edge cases: empty results, single page, exact multiples of page size, and very large page numbers. These often expose bugs in custom pagination views.






Comments
Loading comments…