Caching and pagination¶
Cache¶
$rates = $cache->remember("fx:$currency", 300, fn () => $this->fetchRates($currency));
$cache->set('feature-flags', $flags, 60);
$cache->delete('feature-flags');
| Binding | When |
|---|---|
InMemoryCache (default) |
Per worker, LRU-bounded by cache.max_items (10,000). Each worker has its own copy, so use it for data that may be briefly stale per node. |
RedisCache (->withRedisStores()) |
Shared by every node; keys are prefixed with app.name. |
- Values must be JSON-compatible (null, scalars, arrays). Objects are refused: cache a DTO's data, not the object, so a cache entry can never instantiate a class when read.
- Stampede protection.
remember()refreshes values early with a probability that grows as expiry nears (XFetch), so a popular key is recomputed by one request before it expires instead of by all of them after. With Redis, a missing key is recomputed by one worker while the others wait up tocache.lock_wait_seconds(default 2) for the result, using a non-blocking sleep under fibers. - Cache keys are yours to scope: include the tenant id for tenant data (
"tenant:$id:plan").
Cursor pagination¶
Offset pagination (?page=500) gets slower with every page and skips or repeats rows when data changes.
Keel's cursors are for keyset pagination: the cursor holds the sort key of the last row of a page.
#[Get('/orders')]
final class ListOrders
{
public function __invoke(ListOrdersQuery $query, Orders $orders, Cursors $cursors): OrderList
{
$after = $query->cursor === null ? null : $cursors->decode($query->cursor, 'orders.list');
$page = $orders->latest($query->limit + 1, $after); // one extra row tells us if there is more
$hasMore = count($page) > $query->limit;
$page = array_slice($page, 0, $query->limit);
$last = end($page);
$next = $hasMore ? $cursors->encode(['c' => $last->createdAt->format(DATE_RFC3339_EXTENDED), 'i' => $last->id], 'orders.list') : null;
return new OrderList(array_map(OrderResource::from(...), $page), new PageInfo($next, $hasMore));
}
}
SELECT * FROM orders
WHERE tenant_id = ? AND (created_at < ? OR (created_at = ? AND id < ?))
ORDER BY created_at DESC, id DESC
LIMIT ?
- Cursors are opaque to clients and signed with a key derived from
app.key: a client cannot forge one to start at an arbitrary position. A tampered cursor is a 400. - The second argument is a purpose label; a cursor from one list cannot be used on another.
PageInfo(nextCursor,hasMore) is a normal output type, so it appears in OpenAPI and the SDK.- Always order by a unique column last (here
id) so the order is total.