Choosing Filament v3 Table Pagination: Simple vs Cursor vs Custom for Large Datasets
Filament v3 tables default to simple pagination with COUNT(*) queries. For tables beyond 50k rows, cursor pagination eliminates deep-offset scans but removes page numbers and total counts. Custom pagination handles composite sort keys. This guide compares all three with validation steps.
05 Sept 2026, 04:33 UTC

The Decision
Filament v3 tables default to Laravel's LengthAwarePaginator (simple pagination). This works fine until your admin panel hits tables beyond ~50,000 rows where COUNT(*) queries and deep OFFSET scans degrade response times. You have three supported options: keep simple pagination with covering indexes, switch to cursor pagination for keyset-based navigation, or inject a custom paginator for composite sort keys or pre-aggregated views. The choice affects query performance, UI capabilities, and migration effort.
Constraints That Drive the Choice
- Data volume: Simple pagination degrades when
COUNT(*)exceeds ~2 seconds orOFFSETexceeds 10,000 rows. - Sort stability: Cursor pagination requires a unique, immutable, sequential sort column (typically the primary key). Duplicate or updated sort values cause skipped or duplicated rows.
- UI requirements: Simple pagination provides jump-to-page, total-row badge, and per-page selector. Cursor pagination only shows prev/next — no page numbers, no total count.
- Relation managers: Nested tables inherit the parent's pagination type unless explicitly overridden; mixing types in one request can produce inconsistent query plans.
- Third-party packages: Packages like
spatie/laravel-query-builderorlaravel-filtermay force simple pagination; verify compatibility before adopting cursor pagination.
Quick Comparison
| Factor | Simple (default) | Cursor | Custom |
|---|---|---|---|
| SQL pattern | SELECT ... LIMIT 25 OFFSET 50000 + COUNT(*) | WHERE id > ? ORDER BY id LIMIT 25 | Depends on injected paginator |
| Total count | Yes (badge, page numbers) | No (hidden) | If paginator implements it |
| Jump to page | Yes | No | If paginator supports it |
| Per-page changes | Works anytime | Ignored after first load unless query key changes | Depends on implementation |
| Sort column requirement | Any orderable column | Unique, immutable, sequential (PK ideal) | Defined by custom logic |
| Migration effort | None | One-line change + index verification | Custom class + service provider binding |
| Best for | Tables < 50k rows, dashboards needing counts | Append-only logs, audit trails, high-volume tables with PK sort | Composite sort keys, materialized views, non-sequential ordering |
Trade-offs in Practice
Simple Pagination: The Safe Default
Filament uses LengthAwarePaginator out of the box. It runs two queries per page load: the data query with LIMIT/OFFSET and a COUNT(*) for the total badge. On InnoDB with a primary key, COUNT(*) is fast up to ~50k rows. Beyond that, the optimizer may scan the clustered index. Deep offsets (OFFSET 100000) force MySQL to read and discard 100k rows before returning 25.
Mitigation: add a covering index on the sort column. For a table sorted by created_at, create INDEX idx_created_at_id (created_at, id). This lets the optimizer satisfy both ORDER BY and LIMIT/OFFSET from the index without touching the clustered index. Still, COUNT(*) remains a full index scan.
Cursor Pagination: Keyset Navigation
Enable with ->paginateType('cursor') on the table class. Filament swaps to CursorPaginator, generating WHERE id > ? ORDER BY id LIMIT 25. No COUNT(*), no OFFSET. The query examines exactly 25 rows regardless of page depth. Latency stays flat whether you're on page 1 or page 10,000.
Cost: the UI loses page numbers, total record badge, and the per-page dropdown becomes ineffective after the first load (cursor paginator caches the per-page value). Users can only move forward/backward. This is acceptable for audit logs, activity feeds, or any append-only dataset where users scroll sequentially.
Critical requirement: the sort column must be unique and never updated. Primary key id satisfies this. created_at alone does not — multiple rows can share the same timestamp. If you must sort by created_at, use a composite cursor key: ORDER BY created_at, id with a custom paginator (see below).
Custom Pagination: Full Control
When cursor pagination's single-column sort isn't enough — e.g., sorting by status, created_at, id or querying a materialized view — inject a custom paginator in the table's query() method:
use Filament\Tables\Table;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Pagination\CursorPaginator;
public function table(Table $table): Table
{
return $table
->query(fn () => $this->getBaseQuery())
->paginateType(fn () => new class($this->getBaseQuery(), 25) extends CursorPaginator {
protected function getCursor(): array
{
return ['status' => $this->cursor['status'] ?? 'published', 'created_at' => $this->cursor['created_at'] ?? now(), 'id' => $this->cursor['id'] ?? 0];
}
protected function getNextCursor(array $items): ?array
{
$last = $items->last();
return $last ? ['status' => $last->status, 'created_at' => $last->created_at, 'id' => $last->id] : null;
}
});
}The custom class must implement Illuminate\\Contracts\\Pagination\\Paginator. Returning a raw array breaks Filament's pagination view components. The example above extends CursorPaginator and overrides cursor encoding/decoding for a composite key. You can also inject a LengthAwarePaginator built from a pre-aggregated materialized view for instant counts on massive datasets.
Concrete Validation Steps
Don't guess — measure. Run these checks at your target data volume before committing.
1. Capture Actual Queries
Enable Laravel Telescope or use DB::listen() in a service provider:
DB::listen(function ($query) {
if (str_contains($query->sql, 'large_table')) {
logger()->info('Filament table query', [
'sql' => $query->sql,
'bindings' => $query->bindings,
'time' => $query->time,
]);
}
});Load the Filament table with each pagination type. Compare query count, duration, and rows_examined in Telescope's Queries tab.
2. EXPLAIN ANALYZE at Depth
Run the generated SQL directly against your database with EXPLAIN ANALYZE:
-- Simple pagination at page 2000 (offset 50000)
EXPLAIN ANALYZE SELECT * FROM `large_table` ORDER BY `id` LIMIT 25 OFFSET 50000;
-- Cursor equivalent
EXPLAIN ANALYZE SELECT * FROM `large_table` WHERE `id` > 50000 ORDER BY `id` LIMIT 25;Verify the simple pagination uses the covering index (look for Using index in Extra) and that cursor pagination shows range access with rows_examined ≈ 25.
3. Automated Regression Test
Add a Pest test asserting the pagination type and SQL shape:
test('audit log table uses cursor pagination', function () {
$table = app(AuditLogTable::class);
expect($table->getPaginationType())->toBe('cursor');
$sql = $table->getTableQuery()->toSql();
expect($sql)->toContain('WHERE `id` > ?')
->not->toContain('OFFSET');
});4. Load Test Concurrent Pagination
Use k6 or Artillery to simulate 50 concurrent users paginating to page 1000. Measure p95 latency and error rate for each pagination type. Simple pagination will show latency growth proportional to offset depth; cursor pagination should remain flat.
Migration Path
- Start with simple pagination (default).
- Add covering index on the sort column:
INDEX idx_sort_col_id (sort_column, id). - Monitor
COUNT(*)latency androws_examinedat your 95th percentile offset. - Switch to
->paginateType('cursor')whenCOUNT(*)> 2s orOFFSET> 10k rows. - Custom only when sort key is composite or you need pre-aggregated counts from a materialized view.
Limitations and Gotchas
- Cursor pagination breaks silently if the sort column isn't unique and immutable. Test with duplicate timestamps or updated sort values before deploying.
- Simple pagination's
COUNT(*)can lock tables under high write load on MyISAM or unindexed InnoDB. Ensure the primary key index exists and InnoDB is used. - Filament's default per-page options
[10, 25, 50, 100]apply to all types. Cursor pagination ignores per-page changes after the first load unless the query key changes (e.g., filter changes). - Relation managers inherit the parent table's pagination type. Override explicitly in the relation manager's
table()method if you need different behavior.
Verification Checklist
- [ ]
EXPLAIN ANALYZEshows index usage for both pagination types at target volume - [ ] Telescope captures zero
COUNT(*)queries when cursor pagination is active - [ ] Pest test passes asserting pagination type and SQL structure
- [ ] Load test shows flat p95 latency for cursor pagination at page 1000+
- [ ] UI review confirms loss of page numbers and total count is acceptable for the use case
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.