Saltar al contenido principal

Paginación

Weaver ORM provides three paginator types for different use cases: Page (standard offset pagination), SimplePage (lightweight, no total count), and CursorPage (cursor-based, ideal for infinite scroll and large datasets).


Standard pagination — paginate()

Call paginate() on any EntityQueryBuilder to execute a count query and a data query simultaneously, returning a Page object.

<?php

$page = $this->productRepository
->query()
->where('is_active', true)
->orderBy('name')
->paginate(page: 2, perPage: 20);

Page object

MethodReturn typeDescription
items()EntityCollectionEntities for the current page
total()intTotal number of matching rows
currentPage()intThe requested page number
lastPage()intThe highest available page number
perPage()intPage size
from()intIndex of the first item on this page (1-based)
to()intIndex of the last item on this page (1-based)
hasMore()boolWhether a next page exists
hasPrevious()boolWhether a previous page exists
<?php

$page = $this->userRepository
->query()
->where('role', 'subscriber')
->orderBy('created_at', 'DESC')
->paginate(page: $request->query->getInt('page', 1), perPage: 25);

// Access data
foreach ($page->items() as $user) {
echo $user->getName();
}

echo "Page {$page->currentPage()} of {$page->lastPage()}";
echo "Showing {$page->from()}{$page->to()} of {$page->total()} users";

if ($page->hasMore()) {
$nextUrl = $this->generateUrl('users_list', ['page' => $page->currentPage() + 1]);
}

Simple pagination — SimplePage

SimplePage skips the COUNT(*) query and only checks whether a next page exists by fetching perPage + 1 rows. Use it when you do not need to show the total count or the last page number (e.g., "Next / Previous" navigation).

<?php

$page = $this->postRepository
->query()
->where('status', 'published')
->orderByDesc('published_at')
->simplePaginate(page: $currentPage, perPage: 15);

// Returns SimplePage:
$page->items(); // EntityCollection for this page
$page->currentPage(); // int
$page->hasMore(); // bool — is there a next page?

SimplePage does not expose total() or lastPage(). It is significantly faster than standard pagination on large tables because it avoids the full-table count.


Cursor pagination — CursorPage

Cursor-based pagination is stateless and consistent: instead of using an OFFSET, it uses an opaque cursor token representing the last seen row. This avoids the performance degradation of large offsets and produces stable results even when rows are inserted or deleted between pages.

<?php

// First page — no cursor
$page = $this->eventRepository
->query()
->orderByDesc('id')
->cursorPaginate(perPage: 50, cursor: null);

$items = $page->items(); // EntityCollection
$cursor = $page->nextCursor(); // opaque string token, or null if no more pages
$hasMore = $page->hasMore();

// Subsequent page — pass the cursor from the previous page
$nextPage = $this->eventRepository
->query()
->orderByDesc('id')
->cursorPaginate(perPage: 50, cursor: $request->query->get('cursor'));

CursorPage object

MethodReturn typeDescription
items()EntityCollectionEntities for this cursor window
nextCursor()?stringOpaque token to pass for the next page, null when on the last page
previousCursor()?stringOpaque token to pass for the previous page
hasMore()boolWhether a next page exists

The cursor value is a base64-encoded JSON payload containing the sort column values of the last row. It is signed to prevent tampering.


Symfony response integration

JSON API response

<?php

namespace App\Controller;

use App\Repository\ProductRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

class ProductController extends AbstractController
{
public function __construct(
private readonly ProductRepository $products,
) {}

#[Route('/api/products', methods: ['GET'])]
public function index(Request $request): JsonResponse
{
$page = $this->products
->query()
->where('is_active', true)
->orderBy('name')
->paginate(
page: $request->query->getInt('page', 1),
perPage: $request->query->getInt('per_page', 20),
);

return $this->json([
'data' => $page->items()->toArray(),
'meta' => [
'current_page' => $page->currentPage(),
'last_page' => $page->lastPage(),
'per_page' => $page->perPage(),
'total' => $page->total(),
'from' => $page->from(),
'to' => $page->to(),
],
'links' => [
'next' => $page->hasMore()
? $this->generateUrl('api_products', ['page' => $page->currentPage() + 1])
: null,
'prev' => $page->hasPrevious()
? $this->generateUrl('api_products', ['page' => $page->currentPage() - 1])
: null,
],
]);
}
}

Cursor-based API

<?php

#[Route('/api/events', methods: ['GET'])]
public function events(Request $request): JsonResponse
{
$page = $this->eventRepository
->query()
->orderByDesc('id')
->cursorPaginate(
perPage: 100,
cursor: $request->query->get('cursor'),
);

return $this->json([
'data' => $page->items()->toArray(),
'next_cursor' => $page->nextCursor(),
'has_more' => $page->hasMore(),
]);
}

Twig template example

<?php

// In your controller:
return $this->render('products/index.html.twig', [
'page' => $this->products->query()->paginate($request->query->getInt('p', 1), 15),
]);
{# templates/products/index.html.twig #}
{% for product in page.items() %}
<div>{{ product.name }}</div>
{% endfor %}

<nav>
{% if page.hasPrevious() %}
<a href="{{ path('products', {p: page.currentPage - 1}) }}">Previous</a>
{% endif %}

Page {{ page.currentPage }} of {{ page.lastPage }}

{% if page.hasMore() %}
<a href="{{ path('products', {p: page.currentPage + 1}) }}">Next</a>
{% endif %}
</nav>

In-memory pagination on EntityCollection

If you already have a fully loaded EntityCollection and want to paginate it in memory (without an additional database query), use the paginate() method directly on the collection:

<?php

$all = $this->categoryRepository->findAll(); // EntityCollection<Category>
$page = $all->paginate(page: 2, perPage: 10); // Page object

This is useful for small, cacheable datasets where loading all rows once and paging in memory is more efficient than repeated offset queries.