- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Database Playbook: Choosing the Right Store for Every System You Build
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You sit down to add pagination to a list-orders endpoint. The domain has a clean OrderRepository port. You open it, and the first question hits before you've typed a character: what's the method signature?
public function listForCustomer(CustomerId $id, int $page, int $perPage): array;
Page and per-page. Simple. Until somebody complains that page 8000 of the admin export takes 14 seconds, and you realize you've baked OFFSET 80000 into the contract every consumer of this port now depends on. Switching to keyset pagination means changing the port. Changing the port means touching every adapter and every use case that calls it.
This is the pagination seam problem in hexagonal PHP. Pagination is half-domain and half-infrastructure, and most codebases get the line wrong. The domain doesn't care that the database can do offset scans; it cares that a list has an order, a size, and a way to ask for "what comes after this." The adapter cares about cursor encoding, index columns, and whether the underlying store can do keyset scans at all.
A Pagination value object lives in the domain. Cursor encoding, offset translation, and SQL belong in the adapter. The contract between them is a Page<T> return type that carries items and a nextCursor.
What the domain actually needs to say
The domain wants three things when it asks for a list:
- A size. How many items per response. Capped.
- A position. "From the start" or "after the thing the previous page ended on."
- An ordering. The business decides the order. Newest first. Highest balance first. Alphabetical. The database does not get to pick.
Notice what's missing. There's no page number, no offset, no cursor string. Those are encodings, and encodings are an adapter concern. The domain says "give me the next 20 orders for this customer, newest first, starting after the one I last saw." The adapter figures out how to express that against PostgreSQL or DynamoDB or an in-memory fake.
Here's the Pagination value object in PHP 8.3, written exactly that way:
<?php
declare(strict_types=1);
namespace App\Domain\Common;
final readonly class Pagination
{
public function __construct(
public Limit $limit,
public ?Cursor $after = null,
) {}
public static function firstPage(int $limit): self
{
return new self(new Limit($limit), null);
}
public function after(Cursor $cursor): self
{
return new self($this->limit, $cursor);
}
}
Limit is its own type because "an int between 1 and 100" is a domain rule, not a database rule. The cap is there to stop an API caller from asking for 50,000 rows. The floor is there because zero items isn't a page.
<?php
declare(strict_types=1);
namespace App\Domain\Common;
final readonly class Limit
{
private const int MIN = 1;
private const int MAX = 100;
public int $value;
public function __construct(int $value)
{
if ($value < self::MIN || $value > self::MAX) {
throw new \DomainException(
"Limit must be between "
. self::MIN . " and " . self::MAX
. ", got {$value}"
);
}
$this->value = $value;
}
}
Cursor is the part most people get wrong. It looks like it should hold an ID, or a timestamp, or a composite key. It shouldn't. From the domain's point of view, a cursor is an opaque token. The domain receives one from the previous page and hands it back to ask for the next. It never inspects the contents.
<?php
declare(strict_types=1);
namespace App\Domain\Common;
final readonly class Cursor
{
public function __construct(public string $token)
{
if ($token === '') {
throw new \DomainException('Cursor token cannot be empty');
}
}
}
That's a 20-line value object that holds a string. The reason it's a class and not a string parameter is type discipline. A Cursor can't accidentally be passed where a customer ID is expected, and the port signature reads like an English sentence. Same payoff you get from any tiny value object: the type checker does work the linter used to.
The port speaks domain, not SQL
With the value objects above, the repository port reads like the domain question it's answering:
<?php
declare(strict_types=1);
namespace App\Domain\Order;
use App\Domain\Common\Pagination;
use App\Domain\Customer\CustomerId;
interface OrderRepository
{
public function listForCustomer(
CustomerId $customerId,
Pagination $pagination,
): Page;
}
And Page is a generic-ish carrier — PHP doesn't have proper generics, but the shape is:
<?php
declare(strict_types=1);
namespace App\Domain\Common;
final readonly class Page
{
/**
* @param list<object> $items
*/
public function __construct(
public array $items,
public ?Cursor $nextCursor,
) {}
public function hasMore(): bool
{
return $this->nextCursor !== null;
}
}
That's the whole contract. items is what you asked for. nextCursor is null when you've reached the end and a fresh Cursor otherwise. The use case calling this port doesn't know whether the underlying store paginated by keyset, offset, or by hand-rolling row numbers. It hands back the cursor it received and asks for more.
A use case using it looks like this — note how plain it reads:
<?php
declare(strict_types=1);
namespace App\Application\Order;
use App\Domain\Common\Cursor;
use App\Domain\Common\Pagination;
use App\Domain\Customer\CustomerId;
use App\Domain\Order\OrderRepository;
final readonly class ListCustomerOrders
{
public function __construct(
private OrderRepository $orders,
) {}
public function __invoke(
string $customerId,
int $limit,
?string $afterToken,
): array {
$pagination = $afterToken === null
? Pagination::firstPage($limit)
: Pagination::firstPage($limit)
->after(new Cursor($afterToken));
$page = $this->orders->listForCustomer(
new CustomerId($customerId),
$pagination,
);
return [
'items' => $page->items,
'next' => $page->nextCursor?->token,
];
}
}
No SQL. No OFFSET. No mention of how the rows come back. The HTTP adapter on top of this is the one that exposes ?after= and ?limit= query parameters; the domain pulls back to its proper job, which is describing what a list is.
The adapter does the SQL — keyset pagination, base64 cursors
Now for the half nobody talks about cleanly: the adapter. The cursor token is opaque to the domain, but it has to mean something concrete to the database. For keyset (also called "seek") pagination, the cursor encodes the position of the last row returned. For an order list sorted by (created_at DESC, id DESC), that's (created_at, id) of the final row.
Encode it as base64 JSON so it stays URL-safe and you can extend the encoded fields later without breaking older clients:
<?php
declare(strict_types=1);
namespace App\Infrastructure\Persistence\Order;
use App\Domain\Common\Cursor;
final readonly class OrderCursorCodec
{
public function encode(\DateTimeImmutable $createdAt, string $id): Cursor
{
$payload = [
'c' => $createdAt->format(\DateTimeInterface::RFC3339_EXTENDED),
'i' => $id,
];
$json = json_encode($payload, JSON_THROW_ON_ERROR);
return new Cursor(
rtrim(strtr(base64_encode($json), '+/', '-_'), '=')
);
}
/**
* @return array{0: \DateTimeImmutable, 1: string}
*/
public function decode(Cursor $cursor): array
{
$b64 = strtr($cursor->token, '-_', '+/');
$b64 .= str_repeat('=', (4 - strlen($b64) % 4) % 4);
$json = base64_decode($b64, true);
if ($json === false) {
throw new \InvalidArgumentException('Malformed cursor');
}
$data = json_decode($json, true, flags: JSON_THROW_ON_ERROR);
return [
new \DateTimeImmutable($data['c']),
(string) $data['i'],
];
}
}
The codec lives in the infrastructure namespace. The domain never imports it. If a future migration moves orders from PostgreSQL to a different store with different sort columns, the codec changes, and the port doesn't.
The repository itself ties the pieces together:
<?php
declare(strict_types=1);
namespace App\Infrastructure\Persistence\Order;
use App\Domain\Common\Page;
use App\Domain\Common\Pagination;
use App\Domain\Customer\CustomerId;
use App\Domain\Order\Order;
use App\Domain\Order\OrderRepository;
use Doctrine\DBAL\Connection;
final readonly class DoctrineOrderRepository implements OrderRepository
{
public function __construct(
private Connection $db,
private OrderCursorCodec $cursors,
private OrderHydrator $hydrator,
) {}
public function listForCustomer(
CustomerId $customerId,
Pagination $pagination,
): Page {
$sql = 'SELECT id, customer_id, total_cents, created_at
FROM orders
WHERE customer_id = :customer_id';
$params = ['customer_id' => $customerId->value];
if ($pagination->after !== null) {
[$afterCreatedAt, $afterId] = $this->cursors->decode(
$pagination->after
);
$sql .= ' AND (created_at, id) < (:after_created, :after_id)';
$params['after_created'] = $afterCreatedAt->format(
'Y-m-d H:i:s.uP'
);
$params['after_id'] = $afterId;
}
$sql .= ' ORDER BY created_at DESC, id DESC LIMIT :limit';
$params['limit'] = $pagination->limit->value + 1;
$rows = $this->db->fetchAllAssociative($sql, $params);
$hasMore = count($rows) > $pagination->limit->value;
if ($hasMore) {
array_pop($rows);
}
$orders = array_map(
fn (array $row): Order => $this->hydrator->fromRow($row),
$rows,
);
$next = null;
if ($hasMore && $rows !== []) {
$last = end($rows);
$next = $this->cursors->encode(
new \DateTimeImmutable($last['created_at']),
(string) $last['id'],
);
}
return new Page($orders, $next);
}
}
The LIMIT :limit + 1 is the standard keyset "do I have more?" probe. Fetch one extra row; if you got it, there's a next page and the extra row tells you the cursor for it.
The (created_at, id) < (:after_created, :after_id) row-value comparison is the part that makes keyset pagination work correctly when timestamps collide. PostgreSQL, MySQL 8.0+, and SQLite all support this syntax. If your stack doesn't, fall back to created_at < :a OR (created_at = :a AND id < :b) — same shape, more verbose.
The in-memory fake — why this layout pays off in tests
Because the port doesn't mention SQL or cursors, the in-memory test double is honest about its own shape:
<?php
declare(strict_types=1);
namespace App\Tests\Doubles;
use App\Domain\Common\Cursor;
use App\Domain\Common\Page;
use App\Domain\Common\Pagination;
use App\Domain\Customer\CustomerId;
use App\Domain\Order\Order;
use App\Domain\Order\OrderRepository;
final class InMemoryOrderRepository implements OrderRepository
{
/** @var array<string, list<Order>> */
private array $byCustomer = [];
public function add(Order $order): void
{
$this->byCustomer[$order->customerId->value][] = $order;
}
public function listForCustomer(
CustomerId $customerId,
Pagination $pagination,
): Page {
$rows = $this->byCustomer[$customerId->value] ?? [];
usort(
$rows,
fn (Order $a, Order $b) => $b->createdAt <=> $a->createdAt,
);
$start = 0;
if ($pagination->after !== null) {
$start = (int) $pagination->after->token + 1;
}
$slice = array_slice($rows, $start, $pagination->limit->value);
$next = ($start + count($slice)) < count($rows)
? new Cursor((string) ($start + count($slice) - 1))
: null;
return new Page($slice, $next);
}
}
The fake's cursor token is just a numeric index. That's fine. It's opaque to the domain, the domain doesn't peek inside, and tests never assert on the string. Two adapters, two encodings, one contract. The use case test doesn't care which one is wired in.
What you keep, what you push out
Keep in the domain:
-
Pagination,Limit,Cursor,Page. Four value objects, all immutable, all readonly. - Ordering decisions when they're a business rule (newest first, by amount, by status). When the order is "whatever the database returns," it's not a domain rule and it doesn't belong here either — but that's a separate post.
- The port signature: it takes a
Pagination, it returns aPage. Noint $page, nostring $cursor, ever.
Push to the adapter:
- Cursor encoding. Base64 JSON, signed JWT, raw column tuple. Adapter's call.
- The choice of keyset vs offset vs Mongo
_id-greater-than. Different stores, different mechanics, same port. - The
+1probe, the row-value comparison, the index hint, the explain plan.
The seam is small and it holds. When somebody complains about page-8000 latency a year from now, you change one adapter file, write a migration to drop the offset code path, and ship. The use case doesn't know it happened. The HTTP contract doesn't break, and the cursor is still a string, still opaque, still works.
That's what hexagonal pays you for: the ability to swap an implementation that's hurting you without rewriting the ten things that depend on it.
If this was useful
Where to draw the line between domain and adapter is the question every chapter of Decoupled PHP circles back to. Pagination is one of the cleaner examples; transactions, error translation, and event delivery are messier and get their own chapters. If you've ever stared at a port signature wondering whether to put SQL-shaped types in it, the book is built around getting that decision right by default.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.



Top comments (0)