Laravel API Resources: Sparse Fieldsets, Cursor Pagination, and Per-Route Rate Limiting
#laravel #api #eloquent #rate-limiting

Laravel API Resources: Sparse Fieldsets, Cursor Pagination, and Per-Route Rate Limiting

4 min read Mohamed Said Mohamed Said

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() replaces OFFSET with 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 Limit objects 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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 When should I prefer cursorPaginate() over paginate() in Laravel?
Use cursorPaginate() whenever you are paginating large tables (hundreds of thousands of rows or more) and do not need random page access. It avoids the OFFSET scan by using a keyset derived from the last seen row, which keeps query time constant regardless of how deep into the dataset you are.
Q02 Can I apply multiple rate limits to a single route in Laravel?
Yes. Return an array of Limit objects from your named limiter closure. Laravel evaluates each limit independently, so you can enforce a per-minute burst cap and a per-day total cap simultaneously on the same route.
Q03 Is the sparse fieldsets approach safe? Could clients request internal columns?
The SparseResource pattern is safe because you define the allowed field map explicitly in toArray(). Clients can only request keys that already exist in that map — they cannot access raw database columns or relationships you have not exposed.

Continue reading

More Articles

View all