The PDO Database Class provides three types of pagination to handle different use cases:
- Full Pagination - Traditional page-number navigation with complete metadata
- Simple Pagination - Faster pagination without total count (ideal for infinite scroll)
- Cursor Pagination - Most efficient for large datasets and real-time data
- Full Pagination
- Simple Pagination
- Cursor Pagination
- URL Generation
- JSON Serialization
- Performance Comparison
- Best Practices
Full pagination provides complete metadata including total count and page numbers. Best for traditional pagination UI with "Page 1 of 10" style navigation.
$result = $db->find()
->from('posts')
->orderBy('created_at', 'DESC')
->paginate(20); // 20 items per page, auto-detect page from $_GET['page']
// Or specify page manually
$result = $db->find()
->from('posts')
->paginate(20, 3); // Page 3// Get items
$items = $result->items();
// Metadata
$total = $result->total(); // Total items across all pages
$perPage = $result->perPage(); // Items per page
$currentPage = $result->currentPage(); // Current page number
$lastPage = $result->lastPage(); // Total pages
// Position info
$from = $result->from(); // First item number on current page
$to = $result->to(); // Last item number on current page
// Pagination state
$hasMore = $result->hasMorePages(); // Are there more pages?
$isFirst = $result->onFirstPage(); // Is this the first page?
$isLast = $result->onLastPage(); // Is this the last page?
// URL generation
$url = $result->url(5); // URL for page 5
$next = $result->nextPageUrl(); // URL for next page (or null)
$prev = $result->previousPageUrl(); // URL for previous page (or null)$result = $db->find()
->from('posts')
->where('status', 'published')
->where('views', 1000, '>')
->orderBy('views', 'DESC')
->paginate(15, 2); // Page 2 of published posts with >1000 views
echo "Showing {$result->from()}-{$result->to()} of {$result->total()} posts\n";Full pagination executes 2 SQL queries:
-- Query 1: Get total count
SELECT COUNT(*) as total FROM posts WHERE status = 'published';
-- Query 2: Get items for current page
SELECT * FROM posts WHERE status = 'published'
ORDER BY views DESC LIMIT 15 OFFSET 15;Simple pagination performs only one query (no COUNT), making it significantly faster than full pagination. Best for infinite scroll or when total count is not needed.
$result = $db->find()
->from('posts')
->orderBy('created_at', 'DESC')
->simplePaginate(20); // 20 items per page
// Or specify page manually
$result = $db->find()
->from('posts')
->simplePaginate(20, 3); // Page 3// Get items
$items = $result->items();
// Metadata
$perPage = $result->perPage(); // Items per page
$currentPage = $result->currentPage(); // Current page number
// Pagination state
$hasMore = $result->hasMorePages(); // Are there more pages?
$isFirst = $result->onFirstPage(); // Is this the first page?
// URL generation
$url = $result->url(3); // URL for page 3
$next = $result->nextPageUrl(); // URL for next page (or null)
$prev = $result->previousPageUrl(); // URL for previous page (or null)// API endpoint for infinite scroll
public function loadMorePosts(int $page = 1): array
{
$result = $this->db->find()
->from('posts')
->where('status', 'published')
->orderBy('created_at', 'DESC')
->simplePaginate(20, $page);
return [
'posts' => $result->items(),
'has_more' => $result->hasMorePages(),
'next_page' => $result->hasMorePages() ? $page + 1 : null,
];
}Simple pagination executes 1 SQL query (fetches +1 item to check if more pages exist):
-- Query: Get items + 1 extra to check for more pages
SELECT * FROM posts WHERE status = 'published'
ORDER BY created_at DESC LIMIT 21 OFFSET 20;
-- If 21 items returned, has_more = true (and we discard the extra item)Cursor pagination is the most efficient for very large datasets. Uses WHERE clauses instead of OFFSET, making it stable when new items are added and very fast on large tables.
// First page
$result = $db->find()
->from('posts')
->orderBy('id', 'DESC') // ORDER BY is required!
->cursorPaginate(20);
// Next page using cursor
$cursor = $result->nextCursor();
$result2 = $db->find()
->from('posts')
->orderBy('id', 'DESC')
->cursorPaginate(20, $cursor);
// Or from query string
// URL: /api/posts?cursor=eyJwYXJhbXMiOnsia...
$result = $db->find()
->from('posts')
->orderBy('id', 'DESC')
->cursorPaginate(20); // Auto-detects cursor from $_GET['cursor']// Get items
$items = $result->items();
// Metadata
$perPage = $result->perPage();
// Pagination state
$hasMore = $result->hasMorePages(); // Are there more pages?
$hasPrev = $result->hasPreviousPages(); // Are there previous pages?
// Cursors (encoded strings)
$nextCursor = $result->nextCursor(); // Cursor for next page
$prevCursor = $result->previousCursor(); // Cursor for previous page
// URL generation
$nextUrl = $result->nextPageUrl();
$prevUrl = $result->previousPageUrl();use tommyknocker\pdodb\query\pagination\Cursor;
// Create cursor manually
$cursor = new Cursor(['id' => 42, 'created_at' => '2025-10-28']);
$encoded = $cursor->encode();
// Decode cursor
$cursor = Cursor::decode($encodedString);
$params = $cursor->parameters(); // ['id' => 42, 'created_at' => '2025-10-28']
// Create from item
$item = ['id' => 123, 'name' => 'Test', 'created_at' => '2025-10-28'];
$cursor = Cursor::fromItem($item, ['id', 'created_at']);Cursor pagination executes 1 SQL query (uses WHERE instead of OFFSET):
-- First page
SELECT * FROM posts ORDER BY id DESC LIMIT 21;
-- Next page (cursor contains id=100)
SELECT * FROM posts WHERE id < 100 ORDER BY id DESC LIMIT 21;
-- Much faster than OFFSET on large tables!- Stable pagination: New items don't affect cursor position
- Performance: No OFFSET performance issues on large tables
- Scalability: Works efficiently with millions of rows
- Requires ORDER BY: Cannot paginate without sorting
- No page numbers: Cannot jump to specific page
- Sequential only: Can only go next/previous
All pagination types support URL generation for easy integration with web applications.
$result = $db->find()
->from('posts')
->paginate(20, 2, [
'path' => '/api/posts',
'query' => ['status' => 'published', 'author' => 'john']
]);
echo $result->url(1);
// Output: /api/posts?status=published&author=john&page=1
echo $result->nextPageUrl();
// Output: /api/posts?status=published&author=john&page=3
echo $result->previousPageUrl();
// Output: /api/posts?status=published&author=john&page=1$result = $db->find()
->from('posts')
->paginate(20);
echo $result->url(5);
// Output: ?page=5$result = $db->find()
->from('posts')
->orderBy('id', 'DESC')
->cursorPaginate(20, null, [
'path' => '/api/posts',
'query' => ['filter' => 'active']
]);
echo $result->nextPageUrl();
// Output: /api/posts?filter=active&cursor=eyJwYXJhbXMiOnsia...All pagination results implement JsonSerializable, making them perfect for JSON APIs.
$result = $db->find()
->from('posts')
->paginate(10, 2);
header('Content-Type: application/json');
echo json_encode($result);Output:
{
"data": [
{"id": 11, "title": "Post 11"},
{"id": 12, "title": "Post 12"}
],
"meta": {
"current_page": 2,
"from": 11,
"to": 20,
"per_page": 10,
"total": 156,
"last_page": 16
},
"links": {
"first": "?page=1",
"last": "?page=16",
"prev": "?page=1",
"next": "?page=3"
}
}$result = $db->find()
->from('posts')
->simplePaginate(10, 2);
echo json_encode($result);Output:
{
"data": [...],
"meta": {
"current_page": 2,
"per_page": 10,
"has_more": true
},
"links": {
"prev": "?page=1",
"next": "?page=3"
}
}$result = $db->find()
->from('posts')
->orderBy('id', 'DESC')
->cursorPaginate(10);
echo json_encode($result);Output:
{
"data": [...],
"meta": {
"per_page": 10,
"has_more": true,
"has_previous": false
},
"cursor": {
"next": "eyJwYXJhbXMiOnsia...",
"prev": null
},
"links": {
"next": "?cursor=eyJwYXJhbXMiOnsia...",
"prev": null
}
}| Type | SQL Queries | 50 rows | 1M rows | Use Case |
|---|---|---|---|---|
| Full Pagination | 2 (COUNT + SELECT) | ~2ms | ~200ms | Page numbers needed |
| Simple Pagination | 1 (SELECT +1) | ~1ms | ~50ms | Infinite scroll |
| Cursor Pagination | 1 (SELECT WHERE) | ~1ms | ~30ms | Large datasets, real-time |
Use when:
- Need to display page numbers (1, 2, 3...)
- Need total count
- Users can jump to specific pages
- Dataset is relatively small (<100k records)
Examples:
- Admin panels
- Search results with "Showing 1-20 of 156"
- Data tables with page navigation
Use when:
- Infinite scroll pattern
- Total count not needed
- Real-time data (total changes frequently)
- Large tables where COUNT(*) is slow
Examples:
- Social media feeds
- Mobile apps with "Load More" button
- Chat messages
- Activity logs
Use when:
- Very large datasets (millions of rows)
- Real-time data with frequent inserts
- Need stable pagination (new items don't affect position)
- Building GraphQL APIs
Examples:
- Time-series data
- Audit logs
- Message history
- Large product catalogs
// ✓ Good - Consistent ordering
$result = $db->find()
->from('posts')
->orderBy('created_at', 'DESC')
->paginate(20);
// ✗ Bad - Unpredictable results without ORDER BY
$result = $db->find()
->from('posts')
->paginate(20);-- For cursor pagination on id
CREATE INDEX idx_posts_id ON posts(id);
-- For sorting by created_at
CREATE INDEX idx_posts_created_at ON posts(created_at DESC);
-- For filtered pagination
CREATE INDEX idx_posts_status_created ON posts(status, created_at DESC);$page = max(1, (int)($_GET['page'] ?? 1)); // Ensure page >= 1
$result = $db->find()
->from('posts')
->paginate(20, $page);// Cache expensive COUNT(*) queries
$cacheKey = 'posts_total_' . md5(serialize($filters));
$total = $cache->get($cacheKey);
if ($total === null) {
$total = $db->find()
->from('posts')
->where($filters)
->select([Db::count()])
->getValue();
$cache->set($cacheKey, $total, 300); // Cache for 5 minutes
}$perPage = min(100, max(10, (int)($_GET['per_page'] ?? 20)));
// Ensures per_page is between 10 and 100// GraphQL-style API endpoint
public function posts(int $first = 20, ?string $after = null): array
{
$result = $this->db->find()
->from('posts')
->orderBy('id', 'DESC')
->cursorPaginate($first, $after);
return [
'edges' => array_map(
fn($post) => ['node' => $post, 'cursor' => $result->nextCursor()],
$result->items()
),
'pageInfo' => [
'hasNextPage' => $result->hasMorePages(),
'hasPreviousPage' => $result->hasPreviousPages(),
'endCursor' => $result->nextCursor(),
],
];
}$result = $db->find()
->from('posts')
->where('status', 'published')
->paginate(20);
if (empty($result->items())) {
return ['message' => 'No posts found', 'data' => []];
}class PostController
{
public function index(Request $request): JsonResponse
{
$result = $this->db->find()
->from('posts')
->where('status', 'published')
->orderBy('created_at', 'DESC')
->paginate(
perPage: $request->get('per_page', 20),
page: $request->get('page', 1),
options: [
'path' => $request->url(),
'query' => $request->query()
]
);
return response()->json($result);
}
}class FeedController
{
public function feed(Request $request): JsonResponse
{
$page = $request->get('page', 1);
$result = $this->db->find()
->from('posts')
->where('user_id', $request->user()->id)
->orderBy('created_at', 'DESC')
->simplePaginate(20, $page);
return response()->json([
'items' => $result->items(),
'next_page' => $result->hasMorePages() ? $page + 1 : null,
]);
}
}class GraphQLResolver
{
public function posts(
int $first = 20,
?string $after = null
): array {
$result = $this->db->find()
->from('posts')
->orderBy('id', 'DESC')
->cursorPaginate($first, $after);
return [
'edges' => array_map(
fn($post) => [
'node' => $post,
'cursor' => base64_encode("post_{$post['id']}")
],
$result->items()
),
'pageInfo' => [
'hasNextPage' => $result->hasMorePages(),
'endCursor' => $result->nextCursor(),
],
];
}
}