gnahotelsolutions/laravel-trailing-slash
Laravel middleware that normalizes trailing slashes on incoming URLs, redirecting requests to your preferred format (with or without a trailing slash) through configurable, SEO-friendly redirects. Easy to install and customize for cleaner, consistent routes.
gnahotelsolutions/laravel-trailing-slash is a small, single-purpose package: it normalises URLs so that every request either has or lacks a trailing slash, issuing a redirect (or rewriting the path) when it doesn't match your chosen convention. It exists to solve the "/blog and /blog/ are two different URLs to Laravel's router but one page to Google" problem.
Minimal setup:
composer require gnahotelsolutions/laravel-trailing-slash — the package is Laravel-agnostic in style (service provider + middleware), so no manual provider registration is needed on Laravel 5.5+.php artisan vendor:publish --provider="Gnahotelsolutions\LaravelTrailingSlash\TrailingSlashServiceProvider" (check the tag listed by php artisan vendor:publish in your version; the provider namespace is the reliable way to target it).web group in app/Http/Kernel.php (or bootstrap/app.php on Laravel 11+, via $middleware->web(append: [...])), after session/start-session middleware but before anything that generates URLs or signs them.php artisan route:clear && php artisan config:clear) and hit a page both with and without a slash to confirm the redirect direction.Where to look first:
First real use case: you're adding canonical URLs and rel=canonical tags ahead of an SEO push, and you discover /services/ and /services both return 200 with slightly different content. Install the package, pick "strip trailing slash" (matches Laravel's default route definitions and most Laravel-generated URLs), and you get a single canonical URL without rewriting every Route::get.
Redirect-based normalisation (the default). The middleware intercepts the request, compares $request->getPathInfo() against the configured style, and returns a redirect to the corrected URL. This is the pattern to reach for on public, GET-heavy sites: it's cacheable by CDNs, and it keeps a single canonical form in search indexes. Apply it globally on the web group.
Scoped application. Don't put it on the global stack. Apply it per route group instead, so you can exclude /api, health checks, webhooks, and anything served by a third party that won't follow redirects:
Route::middleware('trailing-slash')->group(function () {
Route::get('/about', ...);
});
Give the middleware an alias in the kernel and reference the alias, not the FQCN — it makes swapping behaviour in tests trivial.
Route-group prefixing with Route::prefix(). If you serve a section under /docs/, the package's redirect will operate on the full path, including the prefix. Verify that the generated Location header includes the subdirectory when the app is hosted under one (APP_URL with a path); this is the most common misconfiguration.
Paired with canonical tags. Treat the middleware as the enforcement layer and the <link rel="canonical"> as the declaration. Generate canonicals with url()->current() after normalisation, or explicitly rtrim(url()->current(), '/') so the tag never contradicts the redirect.
Integration with signed URLs and pagination. If you use signed routes, generate links after the convention is applied — URL::signedRoute() hashes the path, so a redirect that mutates the path invalidates the signature. For pagination, $paginator->url() builds from the current request path; test page 2 explicitly, because a redirect on /posts/?page=2 that drops the query string silently resets the user to page 1.
Testing pattern. Assert the redirect rather than the final page:
$this->get('/about/')->assertRedirect('/about');
$this->get('/about')->assertOk();
Add one test per HTTP verb you actually serve. assertRedirect also lets you assert the exact Location, which is the cheapest way to catch subdirectory and query-string regressions.
curl -I on a fresh profile) so a misconfiguration doesn't become sticky.POST to /orders/ that gets redirected to /orders becomes a GET (or a bodyless request) on the client side. Exclude non-GET verbs — either via the config if it supports it, or by gating the middleware to $request->isMethod('GET') in a thin wrapper of your own.Route::get('/about') does not match /about/. That means the middleware must run before routing resolves — which it does when registered as normal middleware, but not if you've wired it as a route-level middleware on a route that already failed to match. Symptom: 404s instead of redirects. Fix: apply it on the group, not on individual routes.fetch in a mobile app) may not follow redirects, and third-party webhook providers often won't. Silent 301s on POST /api/... are painful to debug from the caller's side.public/ are unaffected — the middleware only sees requests routed through Laravel. But if you serve a SPA through a catch-all route, the middleware will rewrite client-side routes too; make sure the redirect preserves the fragment (it won't — fragments never reach the server, so the browser handles it, but verify deep-link behaviour).url() and route() helpers are not changed by this package. If your app generates /about/ in Blade today, it will keep generating it; you'll get a redirect on every click. Audit hardcoded trailing slashes in views and JS config, not just server routes./ must be exempt from any "strip" rule, or you'll redirect-loop. Confirm the package short-circuits on / — if you're writing a wrapper, if ($request->getPathInfo() === '/') return $next($request); is mandatory.TrustProxies/TrustHosts so the Location header is built from the correct scheme and host, and before session start if you don't want a session cookie set on a request that's about to be redirected. The reverse (session first) is harmless functionally but writes sessions for crawler redirects.?foo=bar&baz=1 survives the redirect and that percent-encoded paths round-trip. Laravel's getRequestUri() vs getPathInfo() distinction is the usual source of bugs here.vendor:publish --force in case a new key was added with a default that differs from the old hardcoded behaviour.$request->expectsJson() or $request->header('Accept'), and register the wrapper under the same alias. The package's surface area is small enough that a five-line wrapper covers most edge cases.How can I help you explore Laravel packages today?