<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Comment extends Model
{
protected $fillable = [
'post_id',
'author_id',
'body',
];
protected $casts = [
'created_at' => 'datetime',
];
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'author_id');
}
public function scopeForPost($query, int $postId)
{
return $query->where('post_id', $postId);
}
}
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\CommentResource;
use App\Models\Comment;
use Illuminate\Http\Request;
class CommentController extends Controller
{
public function index(Request $request, int $postId)
{
$perPage = min((int) $request->integer('per_page', 20), 100);
$comments = Comment::query()
->forPost($postId)
->with('author:id,name')
->latest() // orders by created_at desc
->orderBy('id') // deterministic tie-breaker for the cursor
->cursorPaginate($perPage)
->withQueryString();
return CommentResource::collection($comments);
}
}
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Pagination\CursorPaginator;
class CommentResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'body' => $this->body,
'author' => [
'id' => $this->author?->id,
'name' => $this->author?->name,
],
'created_at' => $this->created_at?->toIso8601String(),
];
}
public static function collection($resource): AnonymousResourceCollection
{
$collection = parent::collection($resource);
if ($resource instanceof CursorPaginator) {
$collection->additional([
'meta' => [
'per_page' => $resource->perPage(),
'next_cursor' => $resource->nextCursor()?->encode(),
'prev_cursor' => $resource->previousCursor()?->encode(),
'has_more' => $resource->hasMorePages(),
],
]);
}
return $collection;
}
}
Cursor pagination avoids the classic problems of offset pagination on large, frequently-changing tables. Instead of counting rows and skipping OFFSET N (which forces the database to walk and discard rows and can duplicate or skip records when rows are inserted mid-scan), a cursor encodes the position of the last row seen and the query resumes with a WHERE (created_at, id) < (?, ?) comparison against an indexed key. Laravel ships first-class support for this through Model::cursorPaginate(), and this snippet wires it into a JSON API cleanly.
The Comment model shown here is deliberately minimal: it declares $fillable and casts created_at so the ordering column behaves consistently. The important detail is that cursor pagination requires a deterministic, unique ordering. Ordering by created_at alone is not enough because timestamps collide, so the latest() plus an explicit orderBy('id') in the controller gives the compound key that makes the cursor stable and tie-broken.
CommentController is where the pagination happens. It reads per_page but clamps it with min(..., 100) so a client cannot request an unbounded page size. cursorPaginate() returns a CursorPaginator, not a LengthAwarePaginator — there is no total count, which is exactly why it is fast. That paginator is handed to CommentResource::collection(...), letting the resource layer control the JSON shape while the paginator supplies the cursor metadata.
CommentResource defines the per-item representation in toArray(), keeping database columns from leaking directly to clients. The interesting part is the static collection() override that returns an AnonymousResourceCollection whose additional() payload carries next_cursor, prev_cursor, and has_more. The cursor objects returned by nextCursor() and previousCursor() are opaque, base64-encoded strings that already contain the last row's key — clients simply echo them back via ?cursor=, and withQueryString() preserves other filters.
The trade-off is that cursor pagination cannot jump to an arbitrary page or show a total, so it suits infinite-scroll feeds and API cursors rather than numbered page UIs. It also breaks if the ordering columns change between requests, so the sort must be fixed. When those constraints fit, this pattern gives stable, index-friendly pagination that stays fast no matter how deep a client scrolls.
Related snips
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
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
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
Share this code
Here's the card — post it anywhere.