mrshanebarron/pagination
A Laravel pagination package that provides helpers for creating, rendering, and customizing paginated result sets and navigation links, simplifying multi-page data display in Blade templates and API responses.
composer require mrshanebarron/pagination. The package targets Laravel and should be auto-discovered; if your app has dont-discover set or you're on an older Laravel, check composer.json → extra.laravel.providers in the package source and register the provider in bootstrap/providers.php.php artisan vendor:publish --tag=<config-tag>), views second and only if you plan to restyle. Confirm the exact tags with php artisan vendor:publish (interactive list) rather than guessing.page, per_page), and the view/style preset live there. As of v1.0.1 the filename is sb- prefixed — e.g. config/sb-pagination.php. Treat config('sb-pagination.*') as the source of truth; verify the exact key namespace in the stub.php artisan config:clear, then confirm the wiring with php artisan tinker → config('sb-pagination.per_page') and app('<Provider>').// app/Http/Controllers/PostController.php
public function index(PostIndexRequest $request)
{
$posts = Post::query()
->when($request->q, fn ($q, $term) => $q->where('title', 'like', "%{$term}%"))
->latest('id')
->paginate($request->validated('per_page') ?? config('sb-pagination.per_page'))
->withQueryString();
return view('posts.index', compact('posts'));
}
{{-- resources/views/posts/index.blade.php --}}
@foreach ($posts as $post)
<article>{{ $post->title }}</article>
@endforeach
@if ($posts->hasPages())
{{ $posts->links() }}
@endif
If the package's links don't appear by default, the seam is Laravel's own paginator view hook, not the package class: Paginator::defaultView('sb-pagination::links') in a service provider. That single line swaps the package's Blade template in for every paginator in the app.
Centralize the look-and-feel once. Don't call ->links('some-view') at 40 call sites. Register defaults in AppServiceProvider::boot():
use Illuminate\Pagination\Paginator;
Paginator::defaultView('sb-pagination::links'); // full paginator
Paginator::defaultSimpleView('sb-pagination::simple'); // simplePaginate()
Then controllers only decide page size, never markup. Restyling (Tailwind → Bootstrap → custom) becomes a view-publish + edit, not a grep-and-replace.
Validate per_page in a FormRequest, don't trust the query string. Allowlist and clamp:
public function rules(): array
{
return [
'per_page' => ['nullable', 'integer', Rule::in([10, 25, 50, 100])],
'page' => ['nullable', 'integer', 'min:1'],
];
}
An unclamped ?per_page=50000 is a trivial resource-exhaustion vector on a table with a wide select *.
Always chain withQueryString() on filtered or searchable lists. Otherwise clicking page 2 drops ?q=laravel&sort=title and users see an unfiltered list — a bug that ships constantly and never shows up in fresh-database tests.
API responses: keep one envelope across every endpoint. $paginator->toArray() already gives data, current_page, last_page, per_page, total, and a links array of first/prev/next/last. Either return that shape verbatim (and let the package's "structured JSON metadata" do the work) or map it once into a resource so mobile clients don't have to cope with three different paginated shapes:
return PostResource::collection($posts)->additional([
'meta' => [
'current_page' => $posts->currentPage(),
'per_page' => $posts->perPage(),
'total' => $posts->total(),
],
]);
Pick paginate() vs simplePaginate() deliberately. paginate() runs a second count(*) query, which is fine on an indexed single-table list and expensive on a join-heavy or GROUP BY report. simplePaginate() skips the count and gives you Prev/Next only — the right default for infinite scroll and "load more" UIs, where total is never displayed anyway.
Feature-test the paginator, not the markup. Assert on data and metadata (assertViewHas, or $response->json('meta.current_page')), and drive the page number through Laravel's resolver instead of hardcoding ?page=:
Paginator::currentPageResolver(fn () => 3);
Compose with the rest of the stack. A Blade component wrapping {{ $posts->links() }} is the right place for aria-label, <nav> landmarks, and a "Showing 21–40 of 312" summary — the package owns page controls; the summary is your app's job and is the accessibility detail reviewers ask for.
The v1.0.1 filename change is a silent failure. The config was renamed to the sb- prefix. An app with the old filename published gets no error — the package falls back to defaults, so your carefully tuned per_page quietly becomes 15. Rename or republish, then delete the stale file so the next developer doesn't edit the dead one.
Config cache masks every config change. Republishing or renaming while bootstrap/cache/config.php exists does nothing. php artisan config:clear locally, and in deploys always config:cache after the publish step, never before.
--force eats your views. Views are the thing you'll actually customize, and they're the thing vendor:publish --force overwrites. Diff before forcing, or better: copy the package template into resources/views/vendor/ and override there so upgrades land cleanly.
links() renders nothing when there's only one page. hasPages() is false, so a list of 8 items produces empty output — which looks like broken layout in a card footer. Either guard it (as above) or explicitly render a disabled state; don't let the gap ship.
Pagination without a deterministic tiebreaker reorders rows across pages. orderBy('created_at', 'desc') on a column with duplicate timestamps will show the same row on page 1 and page 3. Always append ->orderBy('id') as a final sort key, especially with latest().
Out-of-range ?page=99999 returns an empty page with a 200. Acceptable for apps, bad for SEO and confusing for users hitting a stale bookmark. If it matters, validate against $posts->lastPage() and redirect to the last page.
per_page is also a cache-key concern. If you cache page HTML or API responses keyed on the URL, changing the allowlisted page sizes invalidates nothing — old keys just sit there serving stale totals. Include per_page and the page number in any such key verbatim from the validated request, not from config().
Debugging order: tinker → config('sb-pagination.*') (null means unpublished, wrong prefix, or stale cache) → Paginator::defaultView() → check the view namespace registered in the provider's loadViewsFrom(). Nine times out of ten it's the second step.
Treat this dependency as near-unmaintained code you own. Zero stars, zero dependents, and a single 1.0.1 patch release as of December 2025 means no community safety net and no guaranteed response to issues. Pin it exactly ("mrshanebarron/pagination": "1.0.1"), read the provider — it's small — and check the package's illuminate/support constraint in composer.json before every Laravel major upgrade. The realistic exit ramp is cheap: because the integration surface is only ->links(), config keys, and published views, ripping it out later means swapping Paginator::defaultView() to your own Blade partial.
Supported extension points, in order of preference: published config → published/overridden views → Paginator::defaultView() in your own provider → a wrapper Blade component. Editing vendor/ is never on that list.
How can I help you explore Laravel packages today?