Beyond Basic JsonResource
Most Laravel APIs start with a thin JsonResource wrapper and a paginate() call. That works until your payloads balloon, your cursors drift, and a single client hammers one endpoint. This article tackles three concrete improvements you can ship today.
Sparse Fieldsets Without a Package
JSON:API defines sparse fieldsets (?fields[resource]=id,name,email). You can implement a lightweight version directly in a base resource.
// app/Http/Resources/SparseResource.php
abstract class SparseResource extends JsonResource
{
protected function sparse(array $fields): array
{
$requested = collect(
explode(',', request()->query('fields', ''))
)->filter()->values();
if ($requested->isEmpty()) {
return $fields;
}
return array_intersect_key($fields, array_flip($requested->all()));
}
}
// app/Http/Resources/UserResource.php
class UserResource extends SparseResource
{
public function toArray(Request $request): array
{
return $this->sparse([
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'created_at' => $this->created_at->toISOString(),
]);
}
}
A request to GET /users?fields=id,name now returns only those two keys. No extra package, no reflection magic — just an array_intersect_key on the resolved field map.
Tip: Validate allowed fields in a Form Request to prevent leaking internal column names.
Cursor Pagination for Large Datasets
paginate() uses OFFSET, which forces the database to scan all preceding rows. On a table with millions of records that becomes expensive fast. cursorPaginate() uses a keyset derived from the last seen row.
// routes/api.php
Route::get('/events', function (Request $request) {
return EventResource::collection(
Event::query()
->orderBy('id')
->cursorPaginate(50)
);
});
The response includes next_cursor and prev_cursor tokens. Clients pass ?cursor=<token> on subsequent requests.
What the Query Actually Looks Like
With orderBy('id') and a cursor pointing at id 1000, Laravel generates:
SELECT * FROM events WHERE id > 1000 ORDER BY id ASC LIMIT 51;
That 51 is intentional — Laravel fetches one extra row to determine whether a next page exists, then discards it. The query hits the primary key index regardless of table size.
Caveats:
- Cursor pagination requires a stable, unique sort column (or composite).
- You cannot jump to an arbitrary page — it is forward/backward only.
- Use
CursorPaginator::currentCursorName()if you need a custom query-string key.
Per-Route Rate Limiting with Named Limiters
The global throttle:60,1 middleware is too blunt for a real API. Define named limiters in AppServiceProvider (or a dedicated RateLimitServiceProvider).
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
public function boot(): void
{
RateLimiter::for('exports', function (Request $request) {
return $request->user()
? Limit::perHour(10)->by($request->user()->id)
: Limit::perHour(2)->by($request->ip());
});
RateLimiter::for('search', function (Request $request) {
return [
Limit::perMinute(30)->by($request->user()?->id ?? $request->ip()),
Limit::perDay(5000)->by($request->user()?->id ?? $request->ip()),
];
});
}
Attach them per route:
Route::get('/reports/export', ExportController::class)
->middleware('throttle:exports');
Route::get('/search', SearchController::class)
->middleware('throttle:search');
Returning an array of Limit objects enforces multiple windows simultaneously — a burst guard (per-minute) and a daily budget in one declaration.
Surfacing Limit Headers
Laravel automatically adds X-RateLimit-Limit, X-RateLimit-Remaining, and Retry-After headers when the limiter fires. Clients can back off gracefully without guessing.
Key Takeaways
- Sparse fieldsets reduce payload size with a single
array_intersect_key— no package required. cursorPaginate()replacesOFFSETwith a keyset query; always pair it with an indexed sort column.- Named rate limiters let you apply different burst and daily budgets per endpoint, scoped to authenticated users or IP addresses.
- Returning an array of
Limitobjects from a limiter enforces multiple time windows at once. - Laravel's built-in rate-limit headers give clients everything they need to implement polite retry logic.