e2k/cursor-pagination-bundle
Installation
composer require e2k/cursor-pagination-bundle
Add to config/bundles.php:
E2k\CursorPaginationBundle\CursorPaginationBundle::class => ['all' => true],
First Use Case: Basic Cursor Pagination
Inject CursorQueryFactory into your repository/service:
use E2k\CursorPaginationBundle\Pagination\CursorQueryFactory;
class InvoiceRepository {
public function __construct(private CursorQueryFactory $cursorQueryFactory) {}
}
Define Cursor Fields
Specify fields for cursor-based pagination (e.g., id, createdAt):
$query = $this->cursorQueryFactory
->create(Invoice::class, 'i')
->addCursorField(new CursorFieldDefinition('id', 'i.id', 'string'));
Fetch Paginated Results
$result = $query->paginate($cursor, $limit);
// Returns CursorResult with data, next/prev cursors, and metadata
CursorQueryFactory for building queries.CursorFieldDefinition and FieldDefinition for configuring pagination/filters.Repository Integration Use repositories to encapsulate cursor logic:
class InvoiceRepository {
public function findPaginated(
string $cursor = null,
int $limit = 20,
?string $status = null
): CursorResult {
$query = $this->cursorQueryFactory
->create(Invoice::class, 'i')
->addCursorField(new CursorFieldDefinition('id', 'i.id', 'string'));
if ($status) {
$query->addFilter('status', $status);
}
return $query->paginate($cursor, $limit);
}
}
Controller Layer Pass cursor/limit from request and return JSON:
public function index(Request $request): JsonResponse {
$cursor = $request->query->get('cursor');
$limit = (int)$request->query->get('limit', 20);
$status = $request->query->get('status');
$result = $this->invoiceRepository->findPaginated($cursor, $limit, $status);
return $this->json([
'data' => $result->getItems(),
'next_cursor' => $result->getNextCursor(),
'prev_cursor' => $result->getPreviousCursor(),
]);
}
Filter DSL
Use rich filter expressions (e.g., eq, gt, in):
$query->addFilter('createdAt', '>', new \DateTime('2023-01-01'));
$query->addFilter('status', 'in', ['pending', 'paid']);
CursorQueryFactory to test repositories:
$this->createMock(CursorQueryFactory::class)
->method('create')
->willReturn($mockQuery);
Cursor Field Order
id) are defined before secondary fields (e.g., createdAt):
// Correct: id (string) sorts before createdAt (datetime)
->addCursorField(new CursorFieldDefinition('id', 'i.id', 'string'))
->addCursorField(new CursorFieldDefinition('createdAt', 'i.createdAt', 'datetime'))
Filter Performance
LIKE, JSON operations) may not leverage database indexes.=, >, IN) for optimal performance.Empty Cursors
null as the initial cursor fetches the first page.Case Sensitivity
status) are case-sensitive by default.LOWER() in field definitions for case-insensitive filters:
->addFilterableField(new FieldDefinition('status', 'LOWER(i.status.value)'))
$query->getQueryBuilder()->getQuery()->getSQL();
Custom Field Types
Extend FieldDefinition for unsupported types (e.g., arrays):
class ArrayFieldDefinition extends FieldDefinition {
public function __construct(string $name, string $path, string $type = 'array') {
parent::__construct($name, $path, $type);
}
}
Query Builder Hooks
Override CursorQueryFactory to modify queries:
class CustomCursorQueryFactory extends CursorQueryFactory {
protected function buildQueryBuilder(): QueryBuilder {
$qb = parent::buildQueryBuilder();
$qb->andWhere('i.deletedAt IS NULL'); // Soft-delete filter
return $qb;
}
}
Cursor Encoding Customize cursor serialization (e.g., JSON vs. base64):
$cursor = $result->getNextCursor(); // Default: base64-encoded
$decoded = base64_decode($cursor);
e2k_cursor_pagination by default.user.name).$query->setLimit($limit ?? 20);
How can I help you explore Laravel packages today?