Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 093 / 100

Laravel pagination: a PHP & Laravel guide for developers

Fast pages for large tables.

Expona Admin6 min read

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.

php
// 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 records
  • total() — total record count across all pages
  • currentPage() — current page number
  • lastPage() — final page number
  • hasMorePages() — boolean for conditional rendering
  • links() — HTML pagination links

In a Blade template, render pagination links with:

php
<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.

php
// 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() works

This 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:

php
@if($users->hasMorePages())
    <a href="{{ $users->nextPageUrl() }}">Load more</a>
@endif

The 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.

php
// Cursor pagination by ID
$logs = Log::where('level', 'error')
    ->orderBy('id', 'desc')
    ->cursorPaginate(50);

// In the view or API response
{{ $logs->links() }}
// Generates: ?cursor=eyJpZCI6IDEyMzQ1LCAiX3BvaW50c1RvV2FyZHMiOiAiYiJ9

Cursor 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.

php
// 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.

php
// 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:

php
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:

php
{
  "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:

php
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_page from 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, and total to 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_page values. 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles