Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Fast Paginate Laravel Package

spatie/laravel-fast-paginate

A fast limit/offset paginator for Laravel. The fastPaginate method selects only primary keys first so late pages stay fast, then fetches full rows for that page. Same signature and paginator as Laravel's paginate, so no other code changes.

View on GitHub
Deep Wiki
Context7

Getting Started

spatie/laravel-fast-paginate is a drop-in replacement for paginate() that avoids the classic deep-offset penalty. Instead of SELECT * ... LIMIT 25 OFFSET 250000 (which forces the engine to read every skipped row with all its columns), it runs your query twice:

  1. A key-only query — SELECT id ... LIMIT 25 OFFSET 250000 — which can usually be satisfied entirely from an index.
  2. A second query — SELECT * WHERE id IN (...) — that fetches the full rows for just those 25 keys.

Install and go; there is nothing to publish or configure:

composer require spatie/laravel-fast-paginate

The service provider is auto-discovered and registers a mixin on the Eloquent builder, so fastPaginate() becomes available on every query builder in your app. Start by opening the package's src/FastPaginate.php — it's a handful of methods and reading it end-to-end takes two minutes, which pays off later when you need to reason about ordering and counting.

Your first use case should be an existing slow endpoint. Take the worst offender — typically an admin index or an activity feed with real page numbers — and swap one call:

// Before
return UserResource::collection(User::query()->orderBy('created_at')->paginate(25));

// After
return UserResource::collection(
    User::query()->orderBy('created_at')->orderBy('id')->fastPaginate(25)
);

The return value is a normal LengthAwarePaginator, so ->items(), ->total(), ->links(), and the JSON envelope are unchanged. Nothing on the front end needs to move. Measure before and after with DB::listen() or Laravel Telescope and confirm with EXPLAIN that the key query shows an index-only plan.

Implementation Patterns

Treat it as a swap, not a rewrite. Because the contract is identical to paginate(), the cleanest adoption path is a repository or query-object layer where you can flip the method in one place:

final class InvoiceQuery
{
    public function paginate(array $filters): LengthAwarePaginator
    {
        return Invoice::query()
            ->when($filters['status'] ?? null, fn ($q, $s) => $q->where('status', $s))
            ->when($filters['client'] ?? null, fn ($q, $c) => $q->where('client_id', $c))
            ->orderBy('issued_at', 'desc')
            ->orderBy('id', 'desc')   // tiebreaker, always
            ->fastPaginate(50);
    }
}

Use simpleFastPaginate() for infinite scroll. It returns a Paginator instead of a LengthAwarePaginator, which means no COUNT(*) — often the single most expensive query on a large table. Pair it with a "next page" button or a "load more" button rather than a page-number UI:

$page = $request->integer('page', 1);
$users = User::query()
    ->orderBy('id')
    ->simpleFastPaginate(100, ['*'], 'page', $page);

return response()->json([
    'data' => UserResource::collection($users->items()),
    'has_more' => $users->hasMorePages(),
]);

Register eager loads before you paginate. with(), withCount(), and loadMissing() are applied to the outer query, which is exactly what you want — they run against at most $perPage rows, never against the whole table:

$orders = Order::query()
    ->with(['customer:id,name', 'lines'])
    ->withCount('refunds')
    ->orderBy('placed_at', 'desc')
    ->orderBy('id', 'desc')
    ->fastPaginate(30);

It works on relationship queries. The package qualifies the key against the related model, so the foreign-key constraint is preserved:

$members = $team->members()
    ->orderBy('users.id')
    ->fastPaginate(20);

Reach for it selectively. Keyset pagination (cursorPaginate()) is still the correct tool for user-facing "infinite" lists over very deep data, because it eliminates the offset scan entirely. fastPaginate() earns its keep where you need page numbers and a total — admin panels, exports previews, filtered reports, dashboards with a page selector. Having both in the codebase is normal and healthy.

For bulk work, don't paginate at all. If you're iterating to export or reindex, use chunkById() / lazyById(). Pagination of any flavour re-queries per page and re-runs global scopes each time.

Gotchas and Tips

Ordering must be deterministic — this is the number-one source of bugs. Add the primary key as a final orderBy, in the same direction as the leading sort. Without a unique tiebreaker, rows with identical created_at values can be duplicated on one page and skipped on another, and no amount of caching will hide it. Write a test that fetches every page and asserts the concatenated IDs equal the unpaginated ID list.

Composite primary keys are not supported. The implementation assumes $model->getKeyName() returns a single unique, indexed column. UUIDv7/ULID keys are fine; a pivot table with a two-column key is not — paginate the underlying model instead.

The COUNT(*) is unchanged. fastPaginate() speeds up the page fetch, but a LengthAwarePaginator still needs a total. On a ten-million-row table that count can dominate your latency. Mitigations: use simpleFastPaginate() where the UI allows it, cache the total keyed on the filter set, or return an approximate total (SHOW TABLE STATUS / pg_class.reltuples) and stop promising an exact page count.

Avoid aggregates, groupBy, having, and distinct. The key-only subquery changes what the database is evaluating — a GROUP BY over a key-only select collapses differently than over the full select, and distinct on a key column is a no-op that silently breaks your result set. If you genuinely need those semantics, verify page-by-page against paginate() output. Similarly, a query with a manual ->limit() already applied will carry that limit into the key query.

Include the primary key when you narrow columns. ->fastPaginate(25, ['name', 'email']) hydrates models with a null key, which breaks route-model binding on the resource layer and makes $model->id unusable. Always add the key: ['id', 'name', 'email'].

Global scopes run twice. Soft deletes, tenancy scopes, and any custom global scope are applied to both the key query and the outer query. A scope that joins a large table will erode the benefit — check the EXPLAIN plan, not just the wall clock.

Locks are dangerous here. lockForUpdate() or sharedLock() on the builder gets applied to the key query, then again to the outer query, in two separate statements. Never combine locking with pagination.

Page resolution comes from the request. fastPaginate() resolves the current page via Laravel's paginator resolver, which is the request. In a queued job, artisan command, or test, you'll silently always get page 1 — pass $page explicitly: ->fastPaginate(25, ['*'], 'page', $page).

Debugging checklist. Turn on DB::enableQueryLog() and you'll see three queries where you expected one (key select, count, row fetch) — that's normal, not a bug. If results look wrong, EXPLAIN the key query first: if it shows a filesort or a full scan rather than an index-only plan, the problem is your ORDER BY or WHERE, not the package.

IDE and static analysis support. Because the method arrives via a mixin, neither PHPStorm nor Larastan will see it. Add a docblock to a base builder or use an IDE-helper stub:

/**
 * @method \Illuminate\Pagination\LengthAwarePaginator fastPaginate(int $perPage = null, array $columns = ['*'], string $pageName = 'page', int $page = null)
 * @method \Illuminate\Pagination\Paginator simpleFastPaginate(int $perPage = null, array $columns = ['*'], string $pageName = 'page', int $page = null)
 */
class Builder extends \Illuminate\Database\Eloquent\Builder {}

Extension point. The whole implementation is a single small class. If you need composite-key support, a different key-order function for an exotic database, or a custom count strategy, copy it into app/Support/ and register your own mixin rather than fighting the package — you'll be maintaining a fork of ~100 lines, not a framework.

Pin the version and check the matrix. The package tracks current Laravel releases closely. Confirm the tag you install supports your Laravel and PHP versions before rollout, and run the full test suite plus a staging load test rather than trusting the swap on a small dataset — the performance win only appears once the table is genuinely large.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log