<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\PostResource;
use App\Models\Post;
use Illuminate\Http\Request;
class PostController extends Controller
{
public function index(Request $request)
{
$perPage = (int) $request->integer('per_page', 20);
$perPage = max(1, min($perPage, 100));
$posts = Post::query()
->where('published', true)
->with('author:id,name')
->latest('created_at')
->orderBy('id', 'desc') // deterministic tie-breaker
->cursorPaginate($perPage)
->withQueryString();
return PostResource::collection($posts);
}
}
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PostResource extends JsonResource
{
public static $wrap = 'data';
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'type' => 'post',
'title' => $this->title,
'excerpt' => str($this->body)->limit(160)->value(),
'author' => $this->whenLoaded('author', fn () => [
'id' => $this->author->id,
'name' => $this->author->name,
]),
'published_at' => optional($this->created_at)->toIso8601String(),
];
}
public static function collection($resource)
{
return new PostCollection($resource);
}
}
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class PostCollection extends ResourceCollection
{
public $collects = PostResource::class;
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
];
}
public function with(Request $request): array
{
$paginator = $this->resource;
$next = $paginator->nextCursor();
$prev = $paginator->previousCursor();
return [
'links' => [
'prev' => $paginator->previousPageUrl(),
'next' => $paginator->nextPageUrl(),
],
'meta' => [
'per_page' => $paginator->perPage(),
'has_more' => $paginator->hasMorePages(),
'cursors' => [
'next' => $next ? $next->encode() : null,
'prev' => $prev ? $prev->encode() : null,
],
],
];
}
}
Cursor (keyset) pagination avoids the classic performance trap of OFFSET-based paging: as the offset grows, the database still has to scan and discard every skipped row, so page 5000 is far slower than page 1. Cursor pagination instead remembers the last row seen and asks the database for rows after that position using an indexed WHERE clause, giving stable, constant-time paging even over very large tables. Laravel exposes this through Model::cursorPaginate(), and these three files wire it into a clean JSON API.
In PostController, the index action reads a bounded per_page value and calls cursorPaginate() on a query that is explicitly ordered by created_at and id. The ordering matters: cursor pagination encodes the values of the ordered columns into the opaque cursor, and a tie-breaker column (id) is required so rows with identical timestamps still have a total, deterministic order. Without a unique tie-breaker the cursor could skip or repeat rows. The latest/orderBy pair produces exactly that stable ordering.
The raw CursorPaginator is not returned directly. Instead it is wrapped in PostResource::collection(...), so the controller controls the response shape rather than leaking database columns. PostResource in the second tab maps each model to a trimmed JSON payload and formats timestamps as ISO-8601, which keeps the API contract independent of the schema.
The third tab, PostCollection, overrides toArray and with to build a JSON:API-flavored envelope. It reads nextCursor() and previousCursor() from the underlying paginator and calls ->encode() to turn each cursor object into the URL-safe string clients echo back via the ?cursor= query parameter. Those strings are placed under a meta.cursors block alongside next/prev links generated with previousPageUrl() and nextPageUrl().
A key trade-off is that cursor pagination cannot jump to an arbitrary page number and cannot report a total count cheaply, so it suits infinite-scroll and feed-style endpoints rather than numbered page controls. The hasMorePages() check lets clients stop requesting once the feed is exhausted. Because the cursor is opaque and derived from real column values, the ordering columns must be covered by an index for the approach to pay off.
Related snips
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
Rails.application.configure do
config.after_initialize do
Bullet.enable = true
Bullet.alert = false
Bullet.bullet_logger = true
Bullet.console = true
N+1 query detection with Bullet gem
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
json.array! @posts do |post|
json.cache! ['v1', post], expires_in: 1.hour do
json.id post.id
json.title post.title
json.excerpt post.excerpt
json.published_at post.published_at
Fragment caching for expensive JSON serialization
Share this code
Here's the card — post it anywhere.