ctsoft/laravel-pretty-pagination
A Laravel package that provides clean, customizable pagination views for a more polished UI. Drop-in Blade templates make it easy to replace default Laravel pagination links with attractive, responsive styling and minimal setup.
Install and publish
composer require ctsoft/laravel-pretty-pagination
php artisan vendor:publish --tag=pretty-pagination-config
The service provider is auto-discovered. Open config/pretty-pagination.php first — that is where the URL segment prefix (typically page), the middleware toggle, and any per-route exclusions live. Read this file before touching anything else; the package is thin and almost all behaviour is driven from here.
Wire it in
Register the package middleware on the web group (or only on the routes you want pretty URLs for) in bootstrap/app.php / app/Http/Kernel.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
\Ctsoft\PrettyPagination\Middleware\PrettyPagination::class,
]);
})
First use case
A blog index that paginates as /blog/page/2 instead of /blog?page=2:
// routes/web.php
Route::get('/blog/{page?}', [PostController::class, 'index'])
->where('page', '[0-9]+')
->name('blog.index');
// PostController
public function index()
{
return view('blog.index', [
'posts' => Post::paginate(15),
]);
}
In the Blade view keep using the stock API:
{{ $posts->links() }}
If the URLs render as /blog/page/2, the middleware and config are working. That single check is your smoke test for the whole package.
Middleware-scoped rollout. Don't enable it globally on day one. Apply it to the route groups that need SEO-friendly pagination (blog, category, product listings) and leave admin/API routes on the default ?page= behaviour. This limits the blast radius if a route has a conflicting {slug} segment.
Parameterised listing pages. The common real-world shape is filters + pagination. Keep filters in the query string and let the package own only the page segment:
// /products/page/3?sort=price&brand=acme
$products = Product::query()
->when(request('brand'), fn ($q, $b) => $q->where('brand', $b))
->when(request('sort'), fn ($q, $s) => $q->orderBy($s))
->paginate(24)
->withQueryString();
withQueryString() is the piece that keeps filters attached to the pretty URL. Without it, page 2 silently drops sort and brand.
Canonicalise page 1. Treat /blog/page/1 and /blog as the same resource and 301 the former to the latter. Duplicate content from page-1 variants is the most common SEO regression when adopting this package:
if ((int) request()->route('page') === 1) {
return redirect()->route('blog.index'), 301);
}
Preserve inbound ?page= links. Old bookmarks and external links will still hit /blog?page=2. A short redirect shim (either in the controller or a small middleware placed before the package middleware) converts ?page=N into the pretty form so you don't lose traffic or split analytics.
Pagination in tests. Assert on the pretty URL directly; this catches route-ordering mistakes early:
$this->get('/blog/page/2')
->assertOk()
->assertSee('page/3');
Pair with a route model or where constraint. Always constrain the {page} parameter (->where('page', '[0-9]+')) so a slug like /blog/page/about doesn't get swallowed by the pagination route.
Route ordering is the #1 failure mode. /blog/{page?} will happily match /blog/my-post-slug if registered before /blog/{slug}. Register the paginated listing route after any concrete or slug routes, or constrain {page} to digits. If pagination links start 404-ing, run php artisan route:list --path=blog and check which route wins.
Cache keys must include the page segment. Response caching keyed only on the path minus the page number will serve page 1's HTML for page 3. Include request()->route('page') in the cache key, or exclude paginated pages from full-page caching entirely.
Middleware order matters. The pretty-pagination middleware needs to resolve the page number before anything that reads Paginator::resolveCurrentPage() (e.g. a query-logging or cache middleware). If page numbers come out as 1 everywhere, move the package middleware earlier in the stack.
links() vs. hand-built URLs. If you generate pagination URLs manually (route('blog.index', ['page' => 2])), you will get /blog?page=2 — the route parameter is a path segment only if the route declares {page}. Either declare the segment or route through the paginator's own url() helper.
Last release was September 2023. Verify against your Laravel version before committing. On Laravel 11+ the package's middleware registration assumptions may need adjusting; run the test suite on a branch and check composer why-not if install fails. There are zero dependents, so treat it as low-bus-factor code you may need to fork.
Extension points. The config file is the intended seam — change the URL prefix (/seite/2 for localised sites), toggle the middleware, or exclude routes. If you need different prefixes per section (e.g. /blog/page/2 but /shop/p/4), that usually requires a fork or a thin wrapper middleware rather than config alone.
Debugging checklist when links look wrong: (1) is the middleware actually on this route group — check route:list -v; (2) does the route declare {page?}; (3) is withQueryString() present; (4) is APP_URL correct, since paginator URLs are absolute; (5) clear php artisan route:clear && php artisan config:clear after config edits.
Sitemaps. Pretty URLs are a genuine win here — list /blog/page/N up to the last page in your sitemap instead of query-string variants that crawlers treat inconsistently. Cap it (e.g. first 20 pages) so the sitemap stays useful.
How can I help you explore Laravel packages today?